feat(email): add Correspondence.Provider discriminator #13

Merged
cesnimda merged 1 commits from feat/correspondence-provider-discriminator into main 2026-07-11 19:53:33 +02:00
6 changed files with 59 additions and 0 deletions
Showing only changes of commit cb2715c323 - Show all commits
@@ -0,0 +1,35 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CorrespondenceControllerTests
{
[Fact]
public async Task Create_tags_manually_entered_correspondence_with_manual_provider()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = new CorrespondenceController(db);
var request = new CorrespondenceController.CreateCorrespondenceRequestV2(
job.Id, "Me", "Called to follow up.", "Follow-up call", "Call", null, "outbound", null, null, null, null, null, null);
var result = await controller.Create(request, CancellationToken.None);
Assert.IsType<Correspondence>(((CreatedAtActionResult)result.Result!).Value);
var stored = await db.Correspondences.SingleAsync();
Assert.Equal("manual", stored.Provider);
}
}
@@ -288,6 +288,7 @@ public sealed class GmailControllerTests
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync();
Assert.Single(storedMessages);
Assert.Equal("gmail", storedMessages[0].Provider);
gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()), Times.Once);
}
@@ -159,6 +159,7 @@ namespace JobTrackerApi.Controllers
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
Provider = "manual",
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
@@ -977,6 +977,7 @@ public sealed class GmailController : ControllerBase
GmailAttachmentId = attachment.ExternalAttachmentId,
Inline = attachment.Inline,
})),
Provider = "gmail",
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
@@ -555,6 +555,12 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
// Backfill: historically the only import source was Gmail (rows with an
// ExternalThreadId); everything else was hand-entered. Idempotent — only touches
// rows the app hasn't tagged yet.
Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
@@ -709,6 +715,17 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
using (var backfillGmail = conn.CreateCommand())
{
backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
backfillGmail.ExecuteNonQuery();
}
using (var backfillManual = conn.CreateCommand())
{
backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
backfillManual.ExecuteNonQuery();
}
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
+4
View File
@@ -21,6 +21,10 @@ namespace JobTrackerApi.Models
public string? ExternalTo { get; set; }
public string? ExternalLabelsJson { get; set; }
public string? AttachmentMetadataJson { get; set; }
// Provider discriminator: "gmail" | "microsoft" | "imap" | "manual". Set at the write
// site (import controller or the manual-entry endpoint), not inferred from other fields,
// so it stays correct even for hand-entered rows that happen to carry external-looking data.
public string? Provider { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;