From cb2715c323fcf23f06795a726722f921c906fefd Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Jul 2026 19:03:42 +0200 Subject: [PATCH] feat(email): add Correspondence.Provider discriminator b4 of the multi-provider email roadmap. The manual/free-text correspondence entry path already existed (CorrespondenceController.Create) -- this slice was narrower than the roadmap wording suggests: tag every Correspondence row with which provider it came from (gmail | manual today; microsoft | imap once those providers grow an import-into-Correspondence path of their own), not build a new endpoint. - Correspondence.Provider (nullable string), reconciled via the existing EnsureColumn pattern (SQLite + MySQL). - Idempotent backfill: rows with an ExternalThreadId (historically only ever written by Gmail import) get 'gmail'; everything else gets 'manual'. - GmailController.ImportSingleMessageAsync now tags Provider = "gmail". - CorrespondenceController.Create now tags Provider = "manual". - Both write sites use a fixed literal, not request input -- no injection surface introduced. Backfill SQL is static, no interpolation. 148/148 green (147 existing + 1 new CorrespondenceControllerTests; the GmailController import test gained a Provider assertion in place). Co-Authored-By: Claude Opus 4.8 --- .../CorrespondenceControllerTests.cs | 35 +++++++++++++++++++ JobTrackerApi.Tests/GmailControllerTests.cs | 1 + .../Controllers/CorrespondenceController.cs | 1 + JobTrackerApi/Controllers/GmailController.cs | 1 + .../StartupInitializationExtensions.cs | 17 +++++++++ Models/Correspondence.cs | 4 +++ 6 files changed, 59 insertions(+) create mode 100644 JobTrackerApi.Tests/CorrespondenceControllerTests.cs diff --git a/JobTrackerApi.Tests/CorrespondenceControllerTests.cs b/JobTrackerApi.Tests/CorrespondenceControllerTests.cs new file mode 100644 index 0000000..e28f9e9 --- /dev/null +++ b/JobTrackerApi.Tests/CorrespondenceControllerTests.cs @@ -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(((CreatedAtActionResult)result.Result!).Value); + var stored = await db.Correspondences.SingleAsync(); + Assert.Equal("manual", stored.Provider); + } +} diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index 353dfef..54e40d0 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -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()), Times.Once); } diff --git a/JobTrackerApi/Controllers/CorrespondenceController.cs b/JobTrackerApi/Controllers/CorrespondenceController.cs index f0c5edc..d4d0a4a 100644 --- a/JobTrackerApi/Controllers/CorrespondenceController.cs +++ b/JobTrackerApi/Controllers/CorrespondenceController.cs @@ -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, }; diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index b7b6ffb..7256bc1 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -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, }; diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 1f5ccd6..35e9568 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -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;"); diff --git a/Models/Correspondence.cs b/Models/Correspondence.cs index 7ddc96d..865d0e1 100644 --- a/Models/Correspondence.cs +++ b/Models/Correspondence.cs @@ -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; -- 2.52.0