fix(email): preserve draft send identity
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:
@@ -154,6 +154,7 @@ public sealed class BackgroundWorkerTenantTests
|
|||||||
var draft = Assert.Single(rootElement.GetProperty("EmailDrafts").EnumerateArray());
|
var draft = Assert.Single(rootElement.GetProperty("EmailDrafts").EnumerateArray());
|
||||||
Assert.Equal(owner == "user-1" ? "one@example.test" : "two@example.test", draft.GetProperty("To").GetString());
|
Assert.Equal(owner == "user-1" ? "one@example.test" : "two@example.test", draft.GetProperty("To").GetString());
|
||||||
Assert.Equal(owner == "user-1" ? "Private draft one" : "Private draft two", draft.GetProperty("BodyText").GetString());
|
Assert.Equal(owner == "user-1" ? "Private draft one" : "Private draft two", draft.GetProperty("BodyText").GetString());
|
||||||
|
Assert.True(Guid.TryParse(draft.GetProperty("ClientRequestId").GetString(), out _));
|
||||||
Assert.Equal(1, draft.GetProperty("Revision").GetInt64());
|
Assert.Equal(1, draft.GetProperty("Revision").GetInt64());
|
||||||
}
|
}
|
||||||
owners.Sort(StringComparer.Ordinal);
|
owners.Sort(StringComparer.Ordinal);
|
||||||
@@ -176,6 +177,7 @@ public sealed class BackgroundWorkerTenantTests
|
|||||||
Subject = "Synthetic export subject",
|
Subject = "Synthetic export subject",
|
||||||
BodyText = body,
|
BodyText = body,
|
||||||
ThreadId = "synthetic-thread",
|
ThreadId = "synthetic-thread",
|
||||||
|
ClientRequestId = Guid.NewGuid().ToString("D"),
|
||||||
CreatedAtUtc = DateTime.UtcNow.AddMinutes(-5),
|
CreatedAtUtc = DateTime.UtcNow.AddMinutes(-5),
|
||||||
UpdatedAtUtc = DateTime.UtcNow,
|
UpdatedAtUtc = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public sealed class BackupControllerTests
|
|||||||
Subject = "Synthetic export subject",
|
Subject = "Synthetic export subject",
|
||||||
BodyText = "Synthetic readable draft body.",
|
BodyText = "Synthetic readable draft body.",
|
||||||
ThreadId = "synthetic-thread",
|
ThreadId = "synthetic-thread",
|
||||||
|
ClientRequestId = "00000000-0000-4000-8000-000000000123",
|
||||||
Revision = 3,
|
Revision = 3,
|
||||||
CreatedAtUtc = DateTime.UtcNow.AddMinutes(-5),
|
CreatedAtUtc = DateTime.UtcNow.AddMinutes(-5),
|
||||||
UpdatedAtUtc = DateTime.UtcNow,
|
UpdatedAtUtc = DateTime.UtcNow,
|
||||||
@@ -75,6 +76,7 @@ public sealed class BackupControllerTests
|
|||||||
To = "other@example.test",
|
To = "other@example.test",
|
||||||
Subject = "Other tenant subject",
|
Subject = "Other tenant subject",
|
||||||
BodyText = "Other tenant private body.",
|
BodyText = "Other tenant private body.",
|
||||||
|
ClientRequestId = "00000000-0000-4000-8000-000000000456",
|
||||||
CreatedAtUtc = DateTime.UtcNow,
|
CreatedAtUtc = DateTime.UtcNow,
|
||||||
UpdatedAtUtc = DateTime.UtcNow,
|
UpdatedAtUtc = DateTime.UtcNow,
|
||||||
});
|
});
|
||||||
@@ -102,6 +104,7 @@ public sealed class BackupControllerTests
|
|||||||
Assert.Equal("Synthetic export subject", draft.GetProperty("Subject").GetString());
|
Assert.Equal("Synthetic export subject", draft.GetProperty("Subject").GetString());
|
||||||
Assert.Equal("Synthetic readable draft body.", draft.GetProperty("BodyText").GetString());
|
Assert.Equal("Synthetic readable draft body.", draft.GetProperty("BodyText").GetString());
|
||||||
Assert.Equal("synthetic-thread", draft.GetProperty("ThreadId").GetString());
|
Assert.Equal("synthetic-thread", draft.GetProperty("ThreadId").GetString());
|
||||||
|
Assert.Equal("00000000-0000-4000-8000-000000000123", draft.GetProperty("ClientRequestId").GetString());
|
||||||
Assert.Equal(3, draft.GetProperty("Revision").GetInt64());
|
Assert.Equal(3, draft.GetProperty("Revision").GetInt64());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public sealed class EmailDraftPersistenceTests
|
|||||||
Subject = "Synthetic draft",
|
Subject = "Synthetic draft",
|
||||||
BodyText = "Synthetic private draft body.",
|
BodyText = "Synthetic private draft body.",
|
||||||
ThreadId = "thread-1",
|
ThreadId = "thread-1",
|
||||||
|
ClientRequestId = Guid.NewGuid().ToString("D"),
|
||||||
CreatedAtUtc = DateTime.UtcNow,
|
CreatedAtUtc = DateTime.UtcNow,
|
||||||
UpdatedAtUtc = DateTime.UtcNow,
|
UpdatedAtUtc = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public sealed class EmailDraftsControllerTests
|
|||||||
Assert.Equal("gmail", created.Provider);
|
Assert.Equal("gmail", created.Provider);
|
||||||
Assert.Equal(1, created.Revision);
|
Assert.Equal(1, created.Revision);
|
||||||
Assert.Empty(created.To);
|
Assert.Empty(created.To);
|
||||||
|
Assert.True(Guid.TryParse(created.ClientRequestId, out _));
|
||||||
|
|
||||||
Assert.IsType<BadRequestObjectResult>((await controller.Create(
|
Assert.IsType<BadRequestObjectResult>((await controller.Create(
|
||||||
new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "unknown", "", "", "", null), default)).Result);
|
new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "unknown", "", "", "", null), default)).Result);
|
||||||
@@ -63,6 +64,9 @@ public sealed class EmailDraftsControllerTests
|
|||||||
var ownList = Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
var ownList = Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||||
Assert.IsType<OkObjectResult>((await controller.List(fixture.UserOneJobId, default)).Result).Value);
|
Assert.IsType<OkObjectResult>((await controller.List(fixture.UserOneJobId, default)).Result).Value);
|
||||||
Assert.Equal(fixture.UserOneDraftId, Assert.Single(ownList).Id);
|
Assert.Equal(fixture.UserOneDraftId, Assert.Single(ownList).Id);
|
||||||
|
var allOwnDrafts = Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||||
|
Assert.IsType<OkObjectResult>((await controller.List(null, default)).Result).Value);
|
||||||
|
Assert.Equal(fixture.UserOneDraftId, Assert.Single(allOwnDrafts).Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -72,11 +76,13 @@ public sealed class EmailDraftsControllerTests
|
|||||||
await using (var userOneDb = fixture.Context("user-1"))
|
await using (var userOneDb = fixture.Context("user-1"))
|
||||||
{
|
{
|
||||||
var userOne = Controller(userOneDb, "user-1");
|
var userOne = Controller(userOneDb, "user-1");
|
||||||
|
var originalRequestId = (await userOneDb.EmailDrafts.AsNoTracking().SingleAsync()).ClientRequestId;
|
||||||
var updatedResult = await userOne.Update(fixture.UserOneDraftId,
|
var updatedResult = await userOne.Update(fixture.UserOneDraftId,
|
||||||
new EmailDraftsController.UpdateDraftRequest(1, "new@example.test", "Updated", "Updated body"), default);
|
new EmailDraftsController.UpdateDraftRequest(1, "new@example.test", "Updated", "Updated body"), default);
|
||||||
var updated = Assert.IsType<EmailDraftsController.DraftDto>(Assert.IsType<OkObjectResult>(updatedResult.Result).Value);
|
var updated = Assert.IsType<EmailDraftsController.DraftDto>(Assert.IsType<OkObjectResult>(updatedResult.Result).Value);
|
||||||
Assert.Equal(2, updated.Revision);
|
Assert.Equal(2, updated.Revision);
|
||||||
Assert.Equal("Updated body", updated.BodyText);
|
Assert.Equal("Updated body", updated.BodyText);
|
||||||
|
Assert.Equal(originalRequestId, updated.ClientRequestId);
|
||||||
|
|
||||||
Assert.IsType<ConflictObjectResult>((await userOne.Update(fixture.UserOneDraftId,
|
Assert.IsType<ConflictObjectResult>((await userOne.Update(fixture.UserOneDraftId,
|
||||||
new EmailDraftsController.UpdateDraftRequest(1, "stale@example.test", "Stale", "Stale body"), default)).Result);
|
new EmailDraftsController.UpdateDraftRequest(1, "stale@example.test", "Stale", "Stale body"), default)).Result);
|
||||||
@@ -190,6 +196,7 @@ public sealed class EmailDraftsControllerTests
|
|||||||
To = recipient,
|
To = recipient,
|
||||||
Subject = "Synthetic",
|
Subject = "Synthetic",
|
||||||
BodyText = "Synthetic private draft.",
|
BodyText = "Synthetic private draft.",
|
||||||
|
ClientRequestId = Guid.NewGuid().ToString("D"),
|
||||||
CreatedAtUtc = DateTime.UtcNow,
|
CreatedAtUtc = DateTime.UtcNow,
|
||||||
UpdatedAtUtc = DateTime.UtcNow,
|
UpdatedAtUtc = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
draft.Subject,
|
draft.Subject,
|
||||||
draft.BodyText,
|
draft.BodyText,
|
||||||
draft.ThreadId,
|
draft.ThreadId,
|
||||||
|
draft.ClientRequestId,
|
||||||
draft.Revision,
|
draft.Revision,
|
||||||
draft.CreatedAtUtc,
|
draft.CreatedAtUtc,
|
||||||
draft.UpdatedAtUtc))
|
draft.UpdatedAtUtc))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed class EmailDraftsController(
|
|||||||
string Subject,
|
string Subject,
|
||||||
string BodyText,
|
string BodyText,
|
||||||
string? ThreadId,
|
string? ThreadId,
|
||||||
|
string ClientRequestId,
|
||||||
long Revision,
|
long Revision,
|
||||||
DateTime CreatedAtUtc,
|
DateTime CreatedAtUtc,
|
||||||
DateTime UpdatedAtUtc);
|
DateTime UpdatedAtUtc);
|
||||||
@@ -41,15 +42,17 @@ public sealed class EmailDraftsController(
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<IReadOnlyList<DraftDto>>> List(
|
public async Task<ActionResult<IReadOnlyList<DraftDto>>> List(
|
||||||
[FromQuery] int jobApplicationId,
|
[FromQuery] int? jobApplicationId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ownerUserId = GetOwnerUserId();
|
var ownerUserId = GetOwnerUserId();
|
||||||
if (ownerUserId is null) return Unauthorized();
|
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()
|
var query = db.EmailDrafts.AsNoTracking().Where(draft => draft.OwnerUserId == ownerUserId);
|
||||||
.Where(draft => draft.OwnerUserId == ownerUserId && draft.JobApplicationId == jobApplicationId)
|
if (jobApplicationId.HasValue)
|
||||||
|
query = query.Where(draft => draft.JobApplicationId == jobApplicationId.Value);
|
||||||
|
return Ok(await query
|
||||||
.OrderByDescending(draft => draft.UpdatedAtUtc)
|
.OrderByDescending(draft => draft.UpdatedAtUtc)
|
||||||
.Select(draft => new DraftDto(
|
.Select(draft => new DraftDto(
|
||||||
draft.Id,
|
draft.Id,
|
||||||
@@ -59,6 +62,7 @@ public sealed class EmailDraftsController(
|
|||||||
draft.Subject,
|
draft.Subject,
|
||||||
draft.BodyText,
|
draft.BodyText,
|
||||||
draft.ThreadId,
|
draft.ThreadId,
|
||||||
|
draft.ClientRequestId,
|
||||||
draft.Revision,
|
draft.Revision,
|
||||||
draft.CreatedAtUtc,
|
draft.CreatedAtUtc,
|
||||||
draft.UpdatedAtUtc))
|
draft.UpdatedAtUtc))
|
||||||
@@ -103,6 +107,7 @@ public sealed class EmailDraftsController(
|
|||||||
Subject = subject,
|
Subject = subject,
|
||||||
BodyText = bodyText,
|
BodyText = bodyText,
|
||||||
ThreadId = threadId,
|
ThreadId = threadId,
|
||||||
|
ClientRequestId = Guid.NewGuid().ToString("D"),
|
||||||
Revision = 1,
|
Revision = 1,
|
||||||
CreatedAtUtc = now,
|
CreatedAtUtc = now,
|
||||||
UpdatedAtUtc = now,
|
UpdatedAtUtc = now,
|
||||||
@@ -193,6 +198,7 @@ public sealed class EmailDraftsController(
|
|||||||
draft.Subject,
|
draft.Subject,
|
||||||
draft.BodyText,
|
draft.BodyText,
|
||||||
draft.ThreadId,
|
draft.ThreadId,
|
||||||
|
draft.ClientRequestId,
|
||||||
draft.Revision,
|
draft.Revision,
|
||||||
draft.CreatedAtUtc,
|
draft.CreatedAtUtc,
|
||||||
draft.UpdatedAtUtc);
|
draft.UpdatedAtUtc);
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ namespace JobTrackerApi.Data
|
|||||||
modelBuilder.Entity<EmailDraft>().Property(x => x.To).HasMaxLength(320);
|
modelBuilder.Entity<EmailDraft>().Property(x => x.To).HasMaxLength(320);
|
||||||
modelBuilder.Entity<EmailDraft>().Property(x => x.Subject).HasMaxLength(998);
|
modelBuilder.Entity<EmailDraft>().Property(x => x.Subject).HasMaxLength(998);
|
||||||
modelBuilder.Entity<EmailDraft>().Property(x => x.ThreadId).HasMaxLength(512);
|
modelBuilder.Entity<EmailDraft>().Property(x => x.ThreadId).HasMaxLength(512);
|
||||||
|
modelBuilder.Entity<EmailDraft>().Property(x => x.ClientRequestId).HasMaxLength(128);
|
||||||
modelBuilder.Entity<EmailDraft>()
|
modelBuilder.Entity<EmailDraft>()
|
||||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.UpdatedAtUtc });
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.UpdatedAtUtc });
|
||||||
modelBuilder.Entity<EmailDraft>()
|
modelBuilder.Entity<EmailDraft>()
|
||||||
|
|||||||
+2706
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()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClientRequestId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAtUtc")
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public sealed class EmailDraft
|
|||||||
public string Subject { get; set; } = string.Empty;
|
public string Subject { get; set; } = string.Empty;
|
||||||
public string BodyText { get; set; } = string.Empty;
|
public string BodyText { get; set; } = string.Empty;
|
||||||
public string? ThreadId { get; set; }
|
public string? ThreadId { get; set; }
|
||||||
|
public string ClientRequestId { get; set; } = string.Empty;
|
||||||
public long Revision { get; set; } = 1;
|
public long Revision { get; set; } = 1;
|
||||||
public DateTime CreatedAtUtc { get; set; }
|
public DateTime CreatedAtUtc { get; set; }
|
||||||
public DateTime UpdatedAtUtc { get; set; }
|
public DateTime UpdatedAtUtc { get; set; }
|
||||||
@@ -24,6 +25,7 @@ public sealed record EmailDraftExport(
|
|||||||
string Subject,
|
string Subject,
|
||||||
string BodyText,
|
string BodyText,
|
||||||
string? ThreadId,
|
string? ThreadId,
|
||||||
|
string ClientRequestId,
|
||||||
long Revision,
|
long Revision,
|
||||||
DateTime CreatedAtUtc,
|
DateTime CreatedAtUtc,
|
||||||
DateTime UpdatedAtUtc);
|
DateTime UpdatedAtUtc);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public sealed class DailyExportHostedService(
|
|||||||
draft.Subject,
|
draft.Subject,
|
||||||
draft.BodyText,
|
draft.BodyText,
|
||||||
draft.ThreadId,
|
draft.ThreadId,
|
||||||
|
draft.ClientRequestId,
|
||||||
draft.Revision,
|
draft.Revision,
|
||||||
draft.CreatedAtUtc,
|
draft.CreatedAtUtc,
|
||||||
draft.UpdatedAtUtc))
|
draft.UpdatedAtUtc))
|
||||||
|
|||||||
Reference in New Issue
Block a user