feat(email): add tenant draft persistence
CI and Deploy / test (pull_request) Successful in 4m15s
CI and Deploy / deploy (pull_request) Has been skipped

Add owner-filtered, job-cascading private draft storage with reversible SQLite and MariaDB migration paths. No API or UI exposes draft content yet.
This commit is contained in:
cesnimda
2026-08-10 09:55:16 +02:00
parent ee8efd361d
commit 14b396a452
6 changed files with 2960 additions and 0 deletions
@@ -0,0 +1,77 @@
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 EmailDraftPersistenceTests
{
[Fact]
public async Task Drafts_are_owner_filtered_and_follow_the_owned_job_lifecycle()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
int userOneJobId;
int userTwoJobId;
await using (var seed = Context(options, null))
{
await seed.Database.EnsureCreatedAsync();
var companyOne = new Company { Name = "One", OwnerUserId = "user-1" };
var companyTwo = new Company { Name = "Two", OwnerUserId = "user-2" };
seed.Companies.AddRange(companyOne, companyTwo);
await seed.SaveChangesAsync();
var jobOne = new JobApplication { JobTitle = "Role one", CompanyId = companyOne.Id, OwnerUserId = "user-1" };
var jobTwo = new JobApplication { JobTitle = "Role two", CompanyId = companyTwo.Id, OwnerUserId = "user-2" };
seed.JobApplications.AddRange(jobOne, jobTwo);
await seed.SaveChangesAsync();
userOneJobId = jobOne.Id;
userTwoJobId = jobTwo.Id;
seed.EmailDrafts.AddRange(
Draft("user-1", jobOne.Id, "one@example.test"),
Draft("user-2", jobTwo.Id, "two@example.test"));
await seed.SaveChangesAsync();
}
await using (var userOne = Context(options, "user-1"))
{
var visible = Assert.Single(await userOne.EmailDrafts.AsNoTracking().ToListAsync());
Assert.Equal(userOneJobId, visible.JobApplicationId);
Assert.Equal("one@example.test", visible.To);
var job = await userOne.JobApplications.SingleAsync(item => item.Id == userOneJobId);
userOne.JobApplications.Remove(job);
await userOne.SaveChangesAsync();
}
await using var verify = Context(options, null);
var remaining = Assert.Single(await verify.EmailDrafts.IgnoreQueryFilters().AsNoTracking().ToListAsync());
Assert.Equal("user-2", remaining.OwnerUserId);
Assert.Equal(userTwoJobId, remaining.JobApplicationId);
}
private static EmailDraft Draft(string ownerUserId, int jobApplicationId, string recipient) => new()
{
Id = Guid.NewGuid(),
OwnerUserId = ownerUserId,
JobApplicationId = jobApplicationId,
Provider = "gmail",
To = recipient,
Subject = "Synthetic draft",
BodyText = "Synthetic private draft body.",
ThreadId = "thread-1",
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow,
};
private static JobTrackerContext Context(DbContextOptions<JobTrackerContext> options, string? userId)
{
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
}
}
+16
View File
@@ -62,6 +62,7 @@ namespace JobTrackerApi.Data
public DbSet<UserOperation> UserOperations => Set<UserOperation>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<EmailSendAttempt> EmailSendAttempts => Set<EmailSendAttempt>();
public DbSet<EmailDraft> EmailDrafts => Set<EmailDraft>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -280,6 +281,21 @@ namespace JobTrackerApi.Data
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<EmailDraft>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<EmailDraft>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<EmailDraft>().Property(x => x.Provider).HasMaxLength(32);
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>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.UpdatedAtUtc });
modelBuilder.Entity<EmailDraft>()
.HasOne(x => x.JobApplication)
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TailoredCvDraft>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddEmailDrafts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
CREATE TABLE `EmailDrafts` (
`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,
`To` varchar(320) NOT NULL,
`Subject` varchar(998) NOT NULL,
`BodyText` longtext NOT NULL,
`ThreadId` varchar(512) NULL,
`Revision` bigint NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
CONSTRAINT `PK_EmailDrafts` PRIMARY KEY (`Id`),
CONSTRAINT `FK_EmailDrafts_JobApplications_JobApplicationId`
FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
""");
}
else
{
migrationBuilder.CreateTable(
name: "EmailDrafts",
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),
To = table.Column<string>(type: "TEXT", maxLength: 320, nullable: false),
Subject = table.Column<string>(type: "TEXT", maxLength: 998, nullable: false),
BodyText = table.Column<string>(type: "TEXT", nullable: false),
ThreadId = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
Revision = table.Column<long>(type: "INTEGER", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EmailDrafts", x => x.Id);
table.ForeignKey(
name: "FK_EmailDrafts_JobApplications_JobApplicationId",
column: x => x.JobApplicationId,
principalTable: "JobApplications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
migrationBuilder.CreateIndex(
name: "IX_EmailDrafts_JobApplicationId",
table: "EmailDrafts",
column: "JobApplicationId");
migrationBuilder.CreateIndex(
name: "IX_EmailDrafts_OwnerUserId_JobApplicationId_UpdatedAtUtc",
table: "EmailDrafts",
columns: new[] { "OwnerUserId", "JobApplicationId", "UpdatedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmailDrafts");
}
}
}
@@ -1098,6 +1098,61 @@ namespace JobTrackerApi.Migrations
b.ToTable("CvVariantVersions");
});
modelBuilder.Entity("JobTrackerApi.Models.EmailDraft", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("BodyText")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("Revision")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(998)
.HasColumnType("TEXT");
b.Property<string>("ThreadId")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("To")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.HasIndex("OwnerUserId", "JobApplicationId", "UpdatedAtUtc");
b.ToTable("EmailDrafts");
});
modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b =>
{
b.Property<Guid>("Id")
@@ -2446,6 +2501,17 @@ namespace JobTrackerApi.Migrations
b.Navigation("CvVariant");
});
modelBuilder.Entity("JobTrackerApi.Models.EmailDraft", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany()
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
+17
View File
@@ -0,0 +1,17 @@
namespace JobTrackerApi.Models;
public sealed class EmailDraft
{
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 To { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string BodyText { get; set; } = string.Empty;
public string? ThreadId { get; set; }
public long Revision { get; set; } = 1;
public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
}