Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3081d99355 | |||
| fc62a659ef | |||
| b4fd5e2f96 | |||
| 37ea1f98bb | |||
| ab79072e52 | |||
| abe23b799a | |||
| 6a43227315 | |||
| 9b21d5c65d | |||
| a9a0ddecbc | |||
| 408da93fc7 | |||
| 6db3bffb2f | |||
| c0d620f528 | |||
| cb2715c323 | |||
| d308f1d5d4 | |||
| a8e2f4dc4a | |||
| 8edbdceee9 | |||
| 4f98195592 |
@@ -70,7 +70,12 @@ jobs:
|
||||
CI: 'false'
|
||||
GENERATE_SOURCEMAP: 'false'
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: npm run build
|
||||
# CRA's build (Terser minify + fork-ts-checker workers) has repeatedly died silently on
|
||||
# this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner
|
||||
# class as the npm ci and dotnet-install flakes elsewhere in this workflow). Retry once.
|
||||
run: |
|
||||
npm run build \
|
||||
|| ( echo "Frontend build failed ($?) — retrying once..." && npm run build )
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
||||
// Attachment rows (backlog Wave 3), not manually settable. These tests exercise the single
|
||||
// place they're written: AttachmentsController's Purpose-change and Delete paths.
|
||||
public sealed class AttachmentFlagsRecomputeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Changing_purpose_to_resume_sets_HasResume()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "other");
|
||||
var controller = CreateController(db);
|
||||
|
||||
var result = await controller.Rename(attachment.Id, new AttachmentsController.UpdateAttachmentRequest(null, "resume", null), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||
Assert.True(updated.HasResume);
|
||||
Assert.True(updated.HasOtherAttachment == false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_the_only_resume_attachment_clears_HasResume()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "resume");
|
||||
var controller = CreateController(db);
|
||||
|
||||
var result = await controller.Delete(attachment.Id, CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||
Assert.False(updated.HasResume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_with_case_study_purpose_counts_as_other()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "resume");
|
||||
var controller = CreateController(db);
|
||||
|
||||
await controller.Rename(attachment.Id, new AttachmentsController.UpdateAttachmentRequest(null, "case-study", null), CancellationToken.None);
|
||||
|
||||
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||
Assert.False(updated.HasResume);
|
||||
Assert.True(updated.HasOtherAttachment);
|
||||
}
|
||||
|
||||
private static async Task<(JobApplication Job, Attachment Attachment)> SeedJobWithAttachmentAsync(JobTrackerContext db, string purpose)
|
||||
{
|
||||
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 attachment = new Attachment
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
FileName = "file.pdf",
|
||||
FilePath = Path.Combine(Path.GetTempPath(), $"jobtracker-attachment-test-{Guid.NewGuid():N}.pdf"),
|
||||
FileType = "application/pdf",
|
||||
FileSize = 100,
|
||||
Purpose = purpose,
|
||||
};
|
||||
db.Attachments.Add(attachment);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
job.HasResume = purpose == "resume";
|
||||
job.HasOtherAttachment = purpose is not ("resume" or "cover-letter" or "portfolio");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (job, attachment);
|
||||
}
|
||||
|
||||
private static AttachmentsController CreateController(JobTrackerContext db)
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-attachments-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = tempRoot })
|
||||
.Build();
|
||||
|
||||
var env = new Mock<IHostEnvironment>();
|
||||
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
|
||||
var paths = new AppPaths(config, env.Object);
|
||||
|
||||
return new AttachmentsController(paths, db)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance);
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance);
|
||||
|
||||
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance)
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -91,7 +91,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, NullLogger<AuthController>.Instance)
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -110,6 +110,76 @@ public sealed class AuthAndSystemControllerTests
|
||||
Assert.NotNull(user.GoogleLinkedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exchange_microsoft_token_creates_new_user_when_registration_allowed()
|
||||
{
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
|
||||
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
ApplicationUser? created = null;
|
||||
userManager
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||
.Callback<ApplicationUser>(u => created = u)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var tokenService = new Mock<ITokenService>();
|
||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||
|
||||
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
|
||||
microsoftValidator
|
||||
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
|
||||
.Build();
|
||||
|
||||
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
|
||||
Assert.True(payload.Authenticated);
|
||||
Assert.Equal("microsoft", payload.Provider);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("new.hire@example.com", created!.Email);
|
||||
Assert.Equal("ms-subject", created.MicrosoftSubject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exchange_microsoft_token_rejects_unmatched_account_when_registration_disabled()
|
||||
{
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
|
||||
userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
|
||||
microsoftValidator
|
||||
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null));
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Me_result_includes_google_link_details_for_local_users()
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<ILogger<AuthController>>())
|
||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -202,11 +202,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
CoverLetterText: null,
|
||||
JobUrl: null,
|
||||
DateApplied: null,
|
||||
FeedbackRequestedAt: null,
|
||||
HasResume: null,
|
||||
HasCoverLetter: null,
|
||||
HasPortfolio: null,
|
||||
HasOtherAttachment: null);
|
||||
FeedbackRequestedAt: null);
|
||||
|
||||
var result = await controller.Create(request, CancellationToken.None);
|
||||
|
||||
@@ -255,10 +251,6 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
SalaryPeriod: "fortnight",
|
||||
NextAction: null,
|
||||
FollowUpAt: null,
|
||||
HasResume: null,
|
||||
HasCoverLetter: null,
|
||||
HasPortfolio: null,
|
||||
HasOtherAttachment: null,
|
||||
Notes: null,
|
||||
Description: null,
|
||||
TranslatedDescription: null,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class MicrosoftTokenValidatorTests
|
||||
{
|
||||
private static (IConfiguration Config, Mock<IConfigurationManager<OpenIdConnectConfiguration>> ConfigManager, SymmetricSecurityKey Key) BuildHarness()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:MicrosoftClientId"] = "client-123" })
|
||||
.Build();
|
||||
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret"));
|
||||
var oidc = new OpenIdConnectConfiguration();
|
||||
oidc.SigningKeys.Add(signingKey);
|
||||
|
||||
var configManager = new Mock<IConfigurationManager<OpenIdConnectConfiguration>>();
|
||||
configManager.Setup(x => x.GetConfigurationAsync(It.IsAny<CancellationToken>())).ReturnsAsync(oidc);
|
||||
|
||||
return (config, configManager, signingKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_accepts_tenant_scoped_issuer_and_maps_oid_to_subject()
|
||||
{
|
||||
var (config, configManager, signingKey) = BuildHarness();
|
||||
|
||||
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
||||
issuer: "https://login.microsoftonline.com/9f2c1e3a-tenant/v2.0",
|
||||
audience: "client-123",
|
||||
claims: new[]
|
||||
{
|
||||
new Claim("oid", "ms-subject-1"),
|
||||
new Claim("email", "demo@example.com"),
|
||||
new Claim("given_name", "Demo"),
|
||||
new Claim("family_name", "User"),
|
||||
new Claim("name", "Demo User"),
|
||||
},
|
||||
expires: DateTime.UtcNow.AddMinutes(10),
|
||||
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
|
||||
|
||||
var validator = new MicrosoftTokenValidator(config, configManager.Object);
|
||||
var result = await validator.ValidateAsync(token);
|
||||
|
||||
Assert.Equal("ms-subject-1", result.Subject);
|
||||
Assert.Equal("demo@example.com", result.Email);
|
||||
Assert.True(result.EmailVerified);
|
||||
Assert.Equal("Demo", result.GivenName);
|
||||
Assert.Equal("User", result.FamilyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_rejects_non_microsoft_issuer()
|
||||
{
|
||||
var (config, configManager, signingKey) = BuildHarness();
|
||||
|
||||
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
||||
issuer: "https://evil.example.com/v2.0",
|
||||
audience: "client-123",
|
||||
claims: new[] { new Claim("oid", "ms-subject-1") },
|
||||
expires: DateTime.UtcNow.AddMinutes(10),
|
||||
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
|
||||
|
||||
var validator = new MicrosoftTokenValidator(config, configManager.Object);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => validator.ValidateAsync(token));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,26 @@ namespace JobTrackerApi.Controllers
|
||||
return "other";
|
||||
}
|
||||
|
||||
// JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived
|
||||
// from actual Attachment rows, not manually settable -- this is the single place they're
|
||||
// written, called after every attachment mutation (upload/delete/purpose change) so they
|
||||
// can never drift from what's actually attached.
|
||||
private async Task RecomputeAttachmentFlagsAsync(int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
|
||||
if (job is null) return;
|
||||
|
||||
var purposes = await _db.Attachments
|
||||
.Where(a => a.JobApplicationId == jobId)
|
||||
.Select(a => a.Purpose)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
job.HasResume = purposes.Any(p => p == "resume");
|
||||
job.HasCoverLetter = purposes.Any(p => p == "cover-letter");
|
||||
job.HasPortfolio = purposes.Any(p => p == "portfolio");
|
||||
job.HasOtherAttachment = purposes.Any(p => p is not ("resume" or "cover-letter" or "portfolio"));
|
||||
}
|
||||
|
||||
[HttpGet("{jobId:int}")]
|
||||
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -102,15 +122,23 @@ namespace JobTrackerApi.Controllers
|
||||
att.UseForAi = request.UseForAi.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Purpose))
|
||||
var purposeChanged = !string.IsNullOrWhiteSpace(request.Purpose);
|
||||
if (purposeChanged)
|
||||
{
|
||||
att.Purpose = request.Purpose.Trim().ToLowerInvariant();
|
||||
att.Purpose = request.Purpose!.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
var rawName = (request.FileName ?? string.Empty).Trim();
|
||||
if (rawName.Length == 0)
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
// Recompute needs the Purpose change committed first -- a fresh query
|
||||
// wouldn't see the pending change yet.
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -130,6 +158,11 @@ namespace JobTrackerApi.Controllers
|
||||
att.FileName = name;
|
||||
att.FilePath = newPath;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -141,8 +174,11 @@ namespace JobTrackerApi.Controllers
|
||||
if (att is null) return NotFound();
|
||||
|
||||
var path = att.FilePath;
|
||||
var jobId = att.JobApplicationId;
|
||||
_db.Attachments.Remove(att);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -200,6 +236,8 @@ namespace JobTrackerApi.Controllers
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -19,15 +19,17 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly ITokenService _tokens;
|
||||
private readonly IAppEmailSender _email;
|
||||
private readonly IGoogleTokenValidator _googleTokens;
|
||||
private readonly IMicrosoftTokenValidator _microsoftTokens;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger<AuthController> logger)
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_users = users;
|
||||
_tokens = tokens;
|
||||
_email = email;
|
||||
_googleTokens = googleTokens;
|
||||
_microsoftTokens = microsoftTokens;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -37,12 +39,14 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
var requireAuth = _cfg.GetValue("Auth:Require", false);
|
||||
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
|
||||
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
|
||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
requireAuth,
|
||||
googleEnabled,
|
||||
microsoftEnabled,
|
||||
localEnabled = true,
|
||||
allowRegistration,
|
||||
});
|
||||
@@ -52,6 +56,7 @@ public sealed class AuthController : ControllerBase
|
||||
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
|
||||
public sealed record AuthSessionResult(bool Authenticated, string Provider);
|
||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MeResult(
|
||||
string Provider,
|
||||
string? Id,
|
||||
@@ -64,7 +69,8 @@ public sealed class AuthController : ControllerBase
|
||||
string? ProfileCvStructureJson,
|
||||
string? AvatarImageDataUrl,
|
||||
IList<string> Roles,
|
||||
GoogleLinkDto? GoogleLink);
|
||||
GoogleLinkDto? GoogleLink,
|
||||
MicrosoftLinkDto? MicrosoftLink);
|
||||
private const int MaxAvatarBytes = 1_000_000;
|
||||
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -72,6 +78,7 @@ public sealed class AuthController : ControllerBase
|
||||
};
|
||||
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
|
||||
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
|
||||
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
@@ -155,7 +162,24 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
|
||||
if (!google.EmailVerified || string.IsNullOrWhiteSpace(google.Email))
|
||||
{
|
||||
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
|
||||
}
|
||||
|
||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
if (!allowRegistration)
|
||||
{
|
||||
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
|
||||
}
|
||||
|
||||
user = new ApplicationUser { UserName = google.Email, Email = google.Email, EmailConfirmed = true };
|
||||
var created = await _users.CreateAsync(user);
|
||||
if (!created.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
|
||||
}
|
||||
_logger.LogInformation("Created new user via Google sign-up for {Email}", google.Email);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
|
||||
@@ -173,6 +197,74 @@ public sealed class AuthController : ControllerBase
|
||||
return Ok(new AuthSessionResult(true, "google"));
|
||||
}
|
||||
|
||||
[HttpPost("microsoft/exchange")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = (request.Token ?? string.Empty).Trim();
|
||||
if (token.Length == 0) return BadRequest("Microsoft token is required.");
|
||||
|
||||
MicrosoftTokenPrincipal microsoft;
|
||||
try
|
||||
{
|
||||
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Unauthorized(ex.Message);
|
||||
}
|
||||
|
||||
var user = await _users.Users.FirstOrDefaultAsync(
|
||||
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
|
||||
cancellationToken);
|
||||
|
||||
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
|
||||
{
|
||||
user = await _users.FindByEmailAsync(microsoft.Email);
|
||||
if (user is not null)
|
||||
{
|
||||
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
|
||||
}
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
|
||||
{
|
||||
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
|
||||
}
|
||||
|
||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
if (!allowRegistration)
|
||||
{
|
||||
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
|
||||
}
|
||||
|
||||
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
|
||||
var created = await _users.CreateAsync(user);
|
||||
if (!created.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
|
||||
}
|
||||
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
|
||||
{
|
||||
user.MicrosoftSubject = microsoft.Subject;
|
||||
user.MicrosoftEmail = microsoft.Email;
|
||||
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
|
||||
user.DisplayName ??= TrimOrNull(microsoft.Name);
|
||||
user.FirstName ??= TrimOrNull(microsoft.GivenName);
|
||||
user.LastName ??= TrimOrNull(microsoft.FamilyName);
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "microsoft"));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
@@ -202,7 +294,11 @@ public sealed class AuthController : ControllerBase
|
||||
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
|
||||
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
var iss = User.FindFirstValue("iss") ?? string.Empty;
|
||||
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
|
||||
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase)
|
||||
? "google"
|
||||
: iss.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase)
|
||||
? "microsoft"
|
||||
: "external";
|
||||
|
||||
return Ok(new MeResult(
|
||||
Provider: provider,
|
||||
@@ -216,7 +312,8 @@ public sealed class AuthController : ControllerBase
|
||||
ProfileCvStructureJson: null,
|
||||
AvatarImageDataUrl: null,
|
||||
Roles: Array.Empty<string>(),
|
||||
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
|
||||
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
|
||||
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
@@ -322,6 +419,76 @@ public sealed class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("microsoft/link")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<ActionResult<MicrosoftLinkDto>> LinkMicrosoft([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var token = (request.Token ?? string.Empty).Trim();
|
||||
if (token.Length == 0) return BadRequest("Microsoft token is required.");
|
||||
|
||||
MicrosoftTokenPrincipal microsoft;
|
||||
try
|
||||
{
|
||||
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
var conflict = await _users.Users
|
||||
.Where(x => x.Id != user.Id)
|
||||
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
|
||||
if (conflict is not null)
|
||||
{
|
||||
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
|
||||
}
|
||||
|
||||
user.MicrosoftSubject = microsoft.Subject;
|
||||
user.MicrosoftEmail = microsoft.Email;
|
||||
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
|
||||
user.DisplayName ??= TrimOrNull(microsoft.Name);
|
||||
user.FirstName ??= TrimOrNull(microsoft.GivenName);
|
||||
user.LastName ??= TrimOrNull(microsoft.FamilyName);
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
|
||||
}
|
||||
|
||||
[HttpDelete("microsoft/link")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UnlinkMicrosoft()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.MicrosoftSubject = null;
|
||||
user.MicrosoftEmail = null;
|
||||
user.MicrosoftLinkedAt = null;
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("avatar")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[RequestSizeLimit(MaxAvatarBytes)]
|
||||
@@ -571,6 +738,10 @@ public sealed class AuthController : ControllerBase
|
||||
GoogleLink: new GoogleLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||
Email: user.GoogleEmail,
|
||||
LinkedAt: user.GoogleLinkedAt));
|
||||
LinkedAt: user.GoogleLinkedAt),
|
||||
MicrosoftLink: new MicrosoftLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
|
||||
Email: user.MicrosoftEmail,
|
||||
LinkedAt: user.MicrosoftLinkedAt));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace JobTrackerApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/gmail")]
|
||||
[Authorize]
|
||||
public sealed class GmailController : ControllerBase
|
||||
public sealed partial class GmailController : ControllerBase
|
||||
{
|
||||
private readonly IGmailOAuthService _gmail;
|
||||
private readonly IGmailJobMatchingService _matching;
|
||||
@@ -37,77 +37,6 @@ public sealed class GmailController : ControllerBase
|
||||
private IEmailProvider Email => _providers.Get("gmail")
|
||||
?? throw new InvalidOperationException("Gmail email provider is not registered.");
|
||||
|
||||
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
||||
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
||||
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
||||
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
|
||||
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
|
||||
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
|
||||
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
||||
public sealed record GmailJobMatchedMessageDto(
|
||||
string Id,
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
string From,
|
||||
string To,
|
||||
DateTimeOffset? Date,
|
||||
string Snippet,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool AlreadyImported,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
||||
public sealed record GmailJobMatchedThreadDto(
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool HasImportedMessages,
|
||||
int ImportedMessageCount,
|
||||
int MessageCount,
|
||||
DateTimeOffset? LatestDate,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailJobMatchesResponseDto(
|
||||
int JobApplicationId,
|
||||
string JobTitle,
|
||||
string CompanyName,
|
||||
string? RecruiterName,
|
||||
string? RecruiterEmail,
|
||||
IReadOnlyList<string> Queries,
|
||||
int CandidateMessageCount,
|
||||
int CandidateThreadCount,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
||||
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
||||
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
||||
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
||||
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
||||
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
||||
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
||||
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
||||
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
||||
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
||||
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
||||
|
||||
public sealed record GmailConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? GmailAddress,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -671,7 +600,9 @@ public sealed class GmailController : ControllerBase
|
||||
imported++;
|
||||
}
|
||||
|
||||
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
|
||||
var suggestedJobReviewDecision = await _db.GmailReviewDecisions
|
||||
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
|
||||
UpsertReviewDecision(suggestedJobReviewDecision, ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
|
||||
}
|
||||
@@ -725,8 +656,9 @@ public sealed class GmailController : ControllerBase
|
||||
imported++;
|
||||
}
|
||||
|
||||
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
|
||||
var reviewDecision = await _db.GmailReviewDecisions
|
||||
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
|
||||
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, "linked", job.Id, request.Note);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
|
||||
}
|
||||
@@ -752,10 +684,11 @@ public sealed class GmailController : ControllerBase
|
||||
_db.Correspondences.RemoveRange(messages);
|
||||
}
|
||||
|
||||
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
||||
var reviewDecision = await _db.GmailReviewDecisions
|
||||
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
|
||||
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
|
||||
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
|
||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
|
||||
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, nextDecision, null, request.Note);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
|
||||
}
|
||||
@@ -977,6 +910,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,
|
||||
};
|
||||
@@ -1011,40 +945,6 @@ public sealed class GmailController : ControllerBase
|
||||
return _matching.BuildJobQueries(job, queryOverride);
|
||||
}
|
||||
|
||||
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||
{
|
||||
var bounded = (query ?? string.Empty).Trim();
|
||||
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bounded = string.IsNullOrWhiteSpace(bounded)
|
||||
? $"newer_than:{lookbackDays}d"
|
||||
: $"{bounded} newer_than:{lookbackDays}d";
|
||||
}
|
||||
|
||||
if (!includeSpamTrash)
|
||||
{
|
||||
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
|
||||
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
|
||||
}
|
||||
|
||||
return bounded.Trim();
|
||||
}
|
||||
|
||||
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||
{
|
||||
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||
if (string.IsNullOrWhiteSpace(sample)) return false;
|
||||
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||
{
|
||||
if (!decisions.TryGetValue(threadId, out var existing))
|
||||
@@ -1064,9 +964,11 @@ public sealed class GmailController : ControllerBase
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||
// Single-thread upsert: callers acting on exactly one ThreadId should load just that row
|
||||
// (see the FirstOrDefaultAsync call sites below) rather than every review decision for the
|
||||
// owner just to scan for one match.
|
||||
private void UpsertReviewDecision(GmailReviewDecision? existing, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||
{
|
||||
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new GmailReviewDecision
|
||||
@@ -1074,7 +976,6 @@ public sealed class GmailController : ControllerBase
|
||||
OwnerUserId = ownerUserId,
|
||||
ThreadId = threadId,
|
||||
};
|
||||
decisions.Add(existing);
|
||||
_db.GmailReviewDecisions.Add(existing);
|
||||
}
|
||||
|
||||
@@ -1084,54 +985,6 @@ public sealed class GmailController : ControllerBase
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private static string ToConfidence(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 30 => "high",
|
||||
>= 16 => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractFirstEmail(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
return match.Success ? match.Value : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRecruiterName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var trimmed = value.Split('<')[0].Trim().Trim('"');
|
||||
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? ExtractCompanyName(string? from, string? subject)
|
||||
{
|
||||
var subjectText = (subject ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(subjectText))
|
||||
{
|
||||
var parts = subjectText.Split(new[] { '-', '–', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length >= 2) return parts[0];
|
||||
}
|
||||
|
||||
var recruiterName = ExtractRecruiterName(from);
|
||||
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRoleFromSubject(string? subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject)) return null;
|
||||
var trimmed = subject.Trim();
|
||||
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
|
||||
}
|
||||
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
|
||||
}
|
||||
|
||||
private string GetRequiredOwnerUserId()
|
||||
{
|
||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||
@@ -1166,29 +1019,4 @@ public sealed class GmailController : ControllerBase
|
||||
return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback";
|
||||
}
|
||||
|
||||
private static string BuildPopupHtml(bool success, string message)
|
||||
{
|
||||
var escaped = System.Net.WebUtility.HtmlEncode(message);
|
||||
var status = success ? "connected" : "error";
|
||||
var title = success ? "Gmail connected" : "Gmail connection failed";
|
||||
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
|
||||
return $@"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>Gmail connection</title>
|
||||
</head>
|
||||
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
|
||||
<h2>{title}</h2>
|
||||
<p>{escaped}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
|
||||
}}
|
||||
window.close();
|
||||
</script>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour
|
||||
// change; these were previously nested inline in the controller file).
|
||||
public partial class GmailController
|
||||
{
|
||||
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
||||
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
||||
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
||||
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
|
||||
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
|
||||
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
|
||||
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
||||
public sealed record GmailJobMatchedMessageDto(
|
||||
string Id,
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
string From,
|
||||
string To,
|
||||
DateTimeOffset? Date,
|
||||
string Snippet,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool AlreadyImported,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
||||
public sealed record GmailJobMatchedThreadDto(
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool HasImportedMessages,
|
||||
int ImportedMessageCount,
|
||||
int MessageCount,
|
||||
DateTimeOffset? LatestDate,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailJobMatchesResponseDto(
|
||||
int JobApplicationId,
|
||||
string JobTitle,
|
||||
string CompanyName,
|
||||
string? RecruiterName,
|
||||
string? RecruiterEmail,
|
||||
IReadOnlyList<string> Queries,
|
||||
int CandidateMessageCount,
|
||||
int CandidateThreadCount,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
||||
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
||||
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
||||
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
||||
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
||||
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
||||
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
||||
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
||||
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
||||
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
||||
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
||||
|
||||
public sealed record GmailConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? GmailAddress,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
|
||||
// refactor -- no behaviour change). All are static and side-effect free.
|
||||
public sealed partial class GmailController
|
||||
{
|
||||
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||
{
|
||||
var bounded = (query ?? string.Empty).Trim();
|
||||
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bounded = string.IsNullOrWhiteSpace(bounded)
|
||||
? $"newer_than:{lookbackDays}d"
|
||||
: $"{bounded} newer_than:{lookbackDays}d";
|
||||
}
|
||||
|
||||
if (!includeSpamTrash)
|
||||
{
|
||||
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
|
||||
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
|
||||
}
|
||||
|
||||
return bounded.Trim();
|
||||
}
|
||||
|
||||
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||
{
|
||||
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||
if (string.IsNullOrWhiteSpace(sample)) return false;
|
||||
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ToConfidence(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 30 => "high",
|
||||
>= 16 => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractFirstEmail(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
return match.Success ? match.Value : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRecruiterName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var trimmed = value.Split('<')[0].Trim().Trim('"');
|
||||
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? ExtractCompanyName(string? from, string? subject)
|
||||
{
|
||||
var subjectText = (subject ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(subjectText))
|
||||
{
|
||||
var parts = subjectText.Split(new[] { '-', '–', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length >= 2) return parts[0];
|
||||
}
|
||||
|
||||
var recruiterName = ExtractRecruiterName(from);
|
||||
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRoleFromSubject(string? subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject)) return null;
|
||||
var trimmed = subject.Trim();
|
||||
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
|
||||
}
|
||||
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
|
||||
}
|
||||
|
||||
private static string BuildPopupHtml(bool success, string message)
|
||||
{
|
||||
var escaped = System.Net.WebUtility.HtmlEncode(message);
|
||||
var status = success ? "connected" : "error";
|
||||
var title = success ? "Gmail connected" : "Gmail connection failed";
|
||||
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
|
||||
return $@"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>Gmail connection</title>
|
||||
</head>
|
||||
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
|
||||
<h2>{title}</h2>
|
||||
<p>{escaped}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
|
||||
}}
|
||||
window.close();
|
||||
</script>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
@@ -1376,11 +1376,7 @@ Canonical profile:
|
||||
string? CoverLetterText,
|
||||
string? JobUrl,
|
||||
DateTime? DateApplied,
|
||||
DateTime? FeedbackRequestedAt,
|
||||
bool? HasResume,
|
||||
bool? HasCoverLetter,
|
||||
bool? HasPortfolio,
|
||||
bool? HasOtherAttachment
|
||||
DateTime? FeedbackRequestedAt
|
||||
);
|
||||
|
||||
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
||||
@@ -1422,10 +1418,9 @@ Canonical profile:
|
||||
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
|
||||
FollowUpAt = request.FollowUpAt,
|
||||
FeedbackRequestedAt = request.FeedbackRequestedAt,
|
||||
HasResume = request.HasResume ?? false,
|
||||
HasCoverLetter = request.HasCoverLetter ?? false,
|
||||
HasPortfolio = request.HasPortfolio ?? false,
|
||||
HasOtherAttachment = request.HasOtherAttachment ?? false,
|
||||
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
||||
// Attachment rows (see AttachmentsController.RecomputeAttachmentFlagsAsync), not
|
||||
// settable here -- they start false and get set correctly once files are uploaded.
|
||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes,
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
|
||||
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
|
||||
@@ -1486,10 +1481,6 @@ Canonical profile:
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
bool? HasResume,
|
||||
bool? HasCoverLetter,
|
||||
bool? HasPortfolio,
|
||||
bool? HasOtherAttachment,
|
||||
string? Notes,
|
||||
string? Description,
|
||||
string? TranslatedDescription,
|
||||
@@ -1529,10 +1520,8 @@ Canonical profile:
|
||||
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
|
||||
job.FollowUpAt = request.FollowUpAt;
|
||||
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
|
||||
if (request.HasResume is not null) job.HasResume = request.HasResume.Value;
|
||||
if (request.HasCoverLetter is not null) job.HasCoverLetter = request.HasCoverLetter.Value;
|
||||
if (request.HasPortfolio is not null) job.HasPortfolio = request.HasPortfolio.Value;
|
||||
if (request.HasOtherAttachment is not null) job.HasOtherAttachment = request.HasOtherAttachment.Value;
|
||||
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
||||
// Attachment rows, not settable here -- see AttachmentsController.RecomputeAttachmentFlagsAsync.
|
||||
job.Notes = request.Notes;
|
||||
job.Description = request.Description;
|
||||
job.TranslatedDescription = request.TranslatedDescription;
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<!-- dotnet-ef design-time tooling requires this on the startup project (not just
|
||||
JobTrackerBackend, where the DbContext actually lives) since EF Core 6+. -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Intentionally a no-op. The committed ModelSnapshot had drifted far behind the live schema
|
||||
/// (empty -- see JobTrackerContextModelSnapshot.cs history): every table/column added since
|
||||
/// the last real migration (2026-03-11) was provisioned exclusively through the idempotent
|
||||
/// raw-SQL reconciler in StartupInitializationExtensions.cs, including the ASP.NET Identity
|
||||
/// tables themselves, which have never been created by an EF migration in this repo -- see
|
||||
/// EnsureIdentityTables' comment ("create Identity tables directly if dotnet ef isn't
|
||||
/// available"). `dotnet ef migrations add` scaffolded the honest diff against that stale
|
||||
/// snapshot: full CreateTable/AddColumn operations for schema that already exists on every
|
||||
/// environment (fresh or established) via that reconciler. Applying that diff for real would
|
||||
/// throw "table/column already exists" everywhere. This migration exists only to record itself
|
||||
/// in __EFMigrationsHistory and regenerate JobTrackerContextModelSnapshot.cs to match the
|
||||
/// current C# model, so `dotnet ef migrations add` produces a real (small) diff for the *next*
|
||||
/// schema change instead of scaffolding the whole database again. It changes no data or schema.
|
||||
/// </summary>
|
||||
public partial class SyncModelSnapshot : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,7 @@ builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
|
||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
@@ -209,6 +210,7 @@ builder.Services.AddScoped<JobImportService>();
|
||||
|
||||
var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
|
||||
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
|
||||
var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim();
|
||||
|
||||
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
|
||||
var ephemeralJwtKey = false;
|
||||
@@ -234,7 +236,7 @@ builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.ForwardDefaultSelector = ctx =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(googleClientId))
|
||||
if (string.IsNullOrWhiteSpace(googleClientId) && string.IsNullOrWhiteSpace(microsoftClientId))
|
||||
return "local";
|
||||
|
||||
var auth = ctx.Request.Headers.Authorization.ToString();
|
||||
@@ -250,9 +252,11 @@ builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
var jwt = handler.ReadJwtToken(token);
|
||||
var iss = jwt.Issuer ?? "";
|
||||
return iss is "accounts.google.com" or "https://accounts.google.com"
|
||||
? "google"
|
||||
: "local";
|
||||
if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com")
|
||||
return "google";
|
||||
if (!string.IsNullOrWhiteSpace(microsoftClientId) && iss.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase))
|
||||
return "microsoft";
|
||||
return "local";
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -322,6 +326,23 @@ if (!string.IsNullOrWhiteSpace(googleClientId))
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(microsoftClientId))
|
||||
{
|
||||
builder.Services.AddAuthentication().AddJwtBearer("microsoft", options =>
|
||||
{
|
||||
// Validate Microsoft (Entra ID / personal account) ID tokens as bearer tokens.
|
||||
// "common" authority + ValidateIssuer=false: multi-tenant issuer varies per tenant id.
|
||||
options.Authority = "https://login.microsoftonline.com/common/v2.0";
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = microsoftClientId,
|
||||
ValidateLifetime = true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
if (requireAuth)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record MicrosoftTokenPrincipal(string Subject, string? Email, bool EmailVerified, string? GivenName, string? FamilyName, string? Name);
|
||||
|
||||
public interface IMicrosoftTokenValidator
|
||||
{
|
||||
Task<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
|
||||
|
||||
public MicrosoftTokenValidator(IConfiguration cfg)
|
||||
{
|
||||
_cfg = cfg;
|
||||
// "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants.
|
||||
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
|
||||
new OpenIdConnectConfigurationRetriever());
|
||||
}
|
||||
|
||||
public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_configManager = configManager;
|
||||
}
|
||||
|
||||
public async Task<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var audience = (_cfg["Auth:MicrosoftClientId"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
throw new InvalidOperationException("Microsoft sign-in is not configured.");
|
||||
}
|
||||
|
||||
var config = await _configManager.GetConfigurationAsync(cancellationToken);
|
||||
var handler = new JwtSecurityTokenHandler
|
||||
{
|
||||
// The handler's default inbound claim map rewrites "oid"/"tid" to long Microsoft
|
||||
// schema URIs (an AAD-specific quirk not shared by Google's OIDC claims) -- keep
|
||||
// claim names as issued so FindFirst("oid") below actually matches.
|
||||
MapInboundClaims = false,
|
||||
};
|
||||
// ponytail: multi-tenant "common" app -- each tenant's issuer embeds its own tenant id
|
||||
// (https://login.microsoftonline.com/{tenantId}/v2.0), so issuer is checked by shape below
|
||||
// rather than pinned to one value. Signature/audience/lifetime are still fully validated.
|
||||
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = config.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2),
|
||||
}, out var validatedToken);
|
||||
|
||||
var issuer = (validatedToken as JwtSecurityToken)?.Issuer ?? principal.FindFirst("iss")?.Value ?? "";
|
||||
if (!IsMicrosoftIssuer(issuer))
|
||||
{
|
||||
throw new InvalidOperationException("Microsoft token has an unexpected issuer.");
|
||||
}
|
||||
|
||||
var subject = principal.FindFirst("oid")?.Value?.Trim()
|
||||
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value?.Trim()
|
||||
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
throw new InvalidOperationException("Microsoft token is missing a subject.");
|
||||
}
|
||||
|
||||
var email = principal.FindFirst("email")?.Value?.Trim()
|
||||
?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim()
|
||||
?? principal.FindFirst("preferred_username")?.Value?.Trim();
|
||||
|
||||
return new MicrosoftTokenPrincipal(
|
||||
Subject: subject,
|
||||
Email: email,
|
||||
// Microsoft ID tokens don't carry an email_verified claim; presence of an email claim
|
||||
// from a signature-validated token is treated as verified, same trust level Microsoft's
|
||||
// own APIs give it.
|
||||
EmailVerified: !string.IsNullOrWhiteSpace(email),
|
||||
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
|
||||
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
|
||||
Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim()
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsMicrosoftIssuer(string issuer)
|
||||
=> issuer.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase)
|
||||
&& issuer.EndsWith("/v2.0", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -9,6 +9,186 @@ namespace JobTrackerApi.Services;
|
||||
|
||||
public static class StartupInitializationExtensions
|
||||
{
|
||||
// SQLite-dialect schema helpers. Promoted from local functions to class-level statics so a
|
||||
// second reconciliation pass can run after Migrate() creates the base tables on a brand-new
|
||||
// database (see the CoreSchemaReady-adjacent block near the end of InitializeJobTrackerAsync):
|
||||
// the ad-hoc EnsureColumn calls below no-op on a table that doesn't exist yet, so a genuinely
|
||||
// fresh boot needs them re-run once Migrate() has created JobApplications/Correspondences.
|
||||
private static bool HasTable(DbConnection c, string table)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = "$name";
|
||||
p.Value = table;
|
||||
cmd.Parameters.Add(p);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static bool HasColumn(DbConnection c, string table, string column)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static bool HasMigration(DbConnection c, string migrationId)
|
||||
{
|
||||
if (!HasTable(c, "__EFMigrationsHistory")) return false;
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = "$id";
|
||||
p.Value = migrationId;
|
||||
cmd.Parameters.Add(p);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static void Exec(DbConnection c, string sql)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void EnsureColumn(DbConnection c, string table, string column, string ddl)
|
||||
{
|
||||
// Fresh databases won't have the table until EF migrations run.
|
||||
if (!HasTable(c, table)) return;
|
||||
if (!HasColumn(c, table, column)) Exec(c, ddl);
|
||||
}
|
||||
|
||||
// Ad-hoc columns/backfills added over time without a matching EF migration (the reason the
|
||||
// ModelSnapshot drifted -- see the SyncModelSnapshot migration's doc comment). Safe to call
|
||||
// any number of times against any connection state: every check no-ops if the table or
|
||||
// column doesn't exist yet or already matches.
|
||||
private static void ReconcileCoreAppColumns(DbConnection conn)
|
||||
{
|
||||
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
|
||||
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
|
||||
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;");
|
||||
if (HasTable(conn, "Correspondences"))
|
||||
{
|
||||
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;");
|
||||
}
|
||||
|
||||
// MySQL/MariaDB-dialect equivalents of the helpers above.
|
||||
private static bool HasMySqlTable(DbConnection c, string table)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
|
||||
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
|
||||
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static bool MySqlColumnExists(DbConnection c, string table, string column)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
|
||||
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
|
||||
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
|
||||
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
|
||||
{
|
||||
if (!HasMySqlTable(c, table)) return;
|
||||
if (MySqlColumnExists(c, table, column)) return;
|
||||
using var ddlCmd = c.CreateCommand();
|
||||
ddlCmd.CommandText = ddl;
|
||||
ddlCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// MySQL mirror of ReconcileCoreAppColumns -- same rationale (re-run after Migrate() on a
|
||||
// brand-new database, where these tables didn't exist yet during the pre-Migrate pass).
|
||||
private static void ReconcileCoreAppColumnsMySql(DbConnection conn)
|
||||
{
|
||||
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
|
||||
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;");
|
||||
if (HasMySqlTable(conn, "Correspondences"))
|
||||
{
|
||||
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;");
|
||||
}
|
||||
|
||||
public static Task InitializeJobTrackerAsync(this WebApplication app)
|
||||
{
|
||||
// Apply EF migrations on startup (SQLite dev DB lives in the repo).
|
||||
@@ -61,6 +241,9 @@ public static class StartupInitializationExtensions
|
||||
`GoogleSubject` longtext NULL,
|
||||
`GoogleEmail` longtext NULL,
|
||||
`GoogleLinkedAt` datetime(6) NULL,
|
||||
`MicrosoftSubject` longtext NULL,
|
||||
`MicrosoftEmail` longtext NULL,
|
||||
`MicrosoftLinkedAt` datetime(6) NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
@@ -130,50 +313,6 @@ public static class StartupInitializationExtensions
|
||||
using DbConnection conn = db.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
|
||||
static bool HasTable(DbConnection c, string table)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = "$name";
|
||||
p.Value = table;
|
||||
cmd.Parameters.Add(p);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
static bool HasColumn(DbConnection c, string table, string column)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
static bool HasMigration(DbConnection c, string migrationId)
|
||||
{
|
||||
if (!HasTable(c, "__EFMigrationsHistory")) return false;
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = "$id";
|
||||
p.Value = migrationId;
|
||||
cmd.Parameters.Add(p);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
static void Exec(DbConnection c, string sql)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
static void EnsureColumn(DbConnection c, string table, string column, string ddl)
|
||||
{
|
||||
// Fresh databases won't have the table until EF migrations run.
|
||||
if (!HasTable(c, table)) return;
|
||||
if (!HasColumn(c, table, column)) Exec(c, ddl);
|
||||
}
|
||||
|
||||
static void EnsureIdentityTables(DbConnection c)
|
||||
{
|
||||
// EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available,
|
||||
@@ -217,7 +356,10 @@ public static class StartupInitializationExtensions
|
||||
"AvatarImageDataUrl" TEXT NULL,
|
||||
"GoogleSubject" TEXT NULL,
|
||||
"GoogleEmail" TEXT NULL,
|
||||
"GoogleLinkedAt" TEXT NULL
|
||||
"GoogleLinkedAt" TEXT NULL,
|
||||
"MicrosoftSubject" TEXT NULL,
|
||||
"MicrosoftEmail" TEXT NULL,
|
||||
"MicrosoftLinkedAt" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
@@ -295,6 +437,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
|
||||
|
||||
static void EnsureUserRuleSettingsTable(DbConnection c)
|
||||
{
|
||||
@@ -530,33 +67�,10 @@ �ublic static class StartupInitializationExtensions
|
||||
}
|
||||
|
||||
// Some dev DBs may not match the "legacy" fingerprint above but still lack
|
||||
// the ShortSummary column. Ensure it exists unconditionally if missing.
|
||||
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
|
||||
|
||||
// Structured salary fields (EF maps decimal to TEXT on SQLite).
|
||||
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
|
||||
|
||||
// Ensure ownership columns exist even on non-legacy DBs.
|
||||
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
|
||||
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, "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;");
|
||||
// later ad-hoc columns. Ensure them unconditionally if missing (also re-run once
|
||||
// more after Migrate() below, in case this is a brand-new DB where these tables
|
||||
// didn't exist yet at this point).
|
||||
ReconcileCoreAppColumns(conn);
|
||||
|
||||
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
||||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
|
||||
@@ -580,18 +702,6 @@ public static class StartupInitializationExtensions
|
||||
conn.Open();
|
||||
EnsureIdentityTablesMySql(conn);
|
||||
|
||||
static bool MySqlColumnExists(DbConnection c, string table, string column)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
|
||||
|
||||
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
|
||||
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
|
||||
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
|
||||
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
static bool MySqlIndexExists(DbConnection c, string table, string indexName)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
@@ -604,28 +714,6 @@ public static class StartupInitializationExtensions
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
static bool HasMySqlTable(DbConnection c, string table)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
|
||||
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
|
||||
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
|
||||
{
|
||||
using var existsCmd = c.CreateCommand();
|
||||
existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
|
||||
var ep1 = existsCmd.CreateParameter(); ep1.ParameterName = "@schema"; ep1.Value = c.Database; existsCmd.Parameters.Add(ep1);
|
||||
var ep2 = existsCmd.CreateParameter(); ep2.ParameterName = "@table"; ep2.Value = table; existsCmd.Parameters.Add(ep2);
|
||||
if (existsCmd.ExecuteScalar() is null) return;
|
||||
|
||||
if (MySqlColumnExists(c, table, column)) return;
|
||||
using var ddlCmd = c.CreateCommand();
|
||||
ddlCmd.CommandText = ddl;
|
||||
ddlCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
|
||||
{
|
||||
@@ -665,52 +753,10 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
||||
|
||||
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
|
||||
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, "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;");
|
||||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
|
||||
ReconcileCoreAppColumnsMySql(conn);
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
|
||||
@@ -720,6 +766,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
|
||||
|
||||
if (!HasMySqlTable(conn, "RuleSettings"))
|
||||
{
|
||||
@@ -1168,6 +1217,20 @@ public static class StartupInitializationExtensions
|
||||
app.Logger.LogWarning("Core schema is incomplete after startup initialization. Background services will remain paused until required tables exist.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// On a brand-new database, the ad-hoc-column reconciliation above ran before
|
||||
// Migrate() created JobApplications/Correspondences, so every EnsureColumn call
|
||||
// no-opped. Now that CoreSchemaReady confirms the tables exist (created either just
|
||||
// now by Migrate(), or already, on a prior boot), re-run it -- idempotent, so this is
|
||||
// free on every boot except the very first one, where it's required.
|
||||
if (runtimeProvider is "mysql" or "mariadb")
|
||||
{
|
||||
ReconcileCoreAppColumnsMySql(conn);
|
||||
}
|
||||
else
|
||||
{
|
||||
ReconcileCoreAppColumns(conn);
|
||||
}
|
||||
}
|
||||
|
||||
var readiness = app.Services.GetRequiredService<IStartupReadiness>();
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"JwtExpiresMinutes": 720,
|
||||
"AdminEmail": "admin@example.com",
|
||||
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
|
||||
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID"
|
||||
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID",
|
||||
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
|
||||
},
|
||||
"App": {
|
||||
"PublicBaseUrl": "https://jobs.cesnimda.uk"
|
||||
|
||||
@@ -16,4 +16,7 @@ public sealed class ApplicationUser : IdentityUser
|
||||
public string? GoogleSubject { get; set; }
|
||||
public string? GoogleEmail { get; set; }
|
||||
public DateTimeOffset? GoogleLinkedAt { get; set; }
|
||||
public string? MicrosoftSubject { get; set; }
|
||||
public string? MicrosoftEmail { get; set; }
|
||||
public DateTimeOffset? MicrosoftLinkedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ public class JobApplication
|
||||
public DateTime? FeedbackRequestedAt { get; set; }
|
||||
public string? RecruiterMessageDraft { get; set; }
|
||||
|
||||
// Attachment checklist
|
||||
// Attachment checklist. Derived from Attachment rows, not directly settable by API
|
||||
// consumers -- see AttachmentsController.RecomputeAttachmentFlagsAsync, the single place
|
||||
// these are written, so they can't drift from what's actually attached.
|
||||
public bool HasResume { get; set; } = false;
|
||||
public bool HasCoverLetter { get; set; } = false;
|
||||
public bool HasPortfolio { get; set; } = false;
|
||||
|
||||
Generated
+22
@@ -8,6 +8,7 @@
|
||||
"name": "job-tracker-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^5.17.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
@@ -52,6 +53,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-browser": {
|
||||
"version": "5.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz",
|
||||
"integrity": "sha512-/yTnW2TCk9Mh+2b/NOaHAN+MryUNxzRTaJD/YtrqOA9bpBWfTXn/iyReRbaLrK/btBo3stEzLyEvuWp2NZ5DuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-common": "16.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-common": {
|
||||
"version": "16.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.1.tgz",
|
||||
"integrity": "sha512-yPohvMwWLv1XnaWnIUyKUh8CvcVChCGqG/VluGwfGmaAfrZTNt5yQ+sIs462Sgw6+e2K83KGmMJ860p73ZSCrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^5.17.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
|
||||
@@ -327,10 +327,6 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
|
||||
notes,
|
||||
coverLetterText: null,
|
||||
dateApplied,
|
||||
hasResume: attachments.resume.length > 0,
|
||||
hasCoverLetter: attachments.coverLetter.length > 0,
|
||||
hasPortfolio: attachments.portfolio.length > 0,
|
||||
hasOtherAttachment: attachments.other.length > 0,
|
||||
});
|
||||
|
||||
if (response.data?.id && attachmentCount > 0) {
|
||||
|
||||
@@ -158,10 +158,6 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
salaryPeriod: salaryPeriod || null,
|
||||
nextAction: nextAction.trim() || null,
|
||||
followUpAt: followUpAt || null,
|
||||
hasResume,
|
||||
hasCoverLetter,
|
||||
hasPortfolio,
|
||||
hasOtherAttachment,
|
||||
notes: notes || null,
|
||||
description: description || null,
|
||||
translatedDescription: translatedDescription || null,
|
||||
@@ -243,16 +239,13 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobAttachmentsChecklist")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1, mb: 1.5 }}>
|
||||
{/* Derived from actual uploaded attachments (see the Attachments panel) -- not
|
||||
manually editable, so this can never drift from what's really attached. */}
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
||||
<Chip size="small" label={hasResume ? t("editJobResumeReady") : t("editJobResumeMissing")} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} />
|
||||
<Chip size="small" label={hasCoverLetter ? t("editJobCoverLetterReady") : t("editJobCoverLetterMissing")} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} />
|
||||
<Chip size="small" label={hasPortfolio ? t("editJobPortfolioReady") : t("editJobPortfolioOptional")} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1 }}>
|
||||
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label={t("editJobResume")} />
|
||||
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label={t("editJobCoverLetter")} />
|
||||
<FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label={t("editJobPortfolio")} />
|
||||
<FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label={t("editJobOtherAttachment")} />
|
||||
{hasOtherAttachment && <Chip size="small" label={t("editJobOtherAttachment")} color="success" variant="filled" />}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
|
||||
|
||||
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
|
||||
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
|
||||
// MicrosoftGraphController's BuildPopupHtml); IMAP has no OAuth step, so it's a plain credential
|
||||
// form submitted to POST /api/imap/connect, which verifies the connection before storing it.
|
||||
export default function EmailProviderConnections() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
||||
const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null);
|
||||
const [imapStatus, setImapStatus] = useState<ImapStatus | null>(null);
|
||||
|
||||
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
|
||||
const [imapConnecting, setImapConnecting] = useState(false);
|
||||
|
||||
const loadGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<GmailStatus>("/gmail/status");
|
||||
setGmailStatus(res.data);
|
||||
} catch {
|
||||
setGmailStatus({ connected: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMicrosoftStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<MicrosoftGraphStatus>("/microsoft-graph/status");
|
||||
setMicrosoftStatus(res.data);
|
||||
} catch {
|
||||
setMicrosoftStatus({ connected: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadImapStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<ImapStatus>("/imap/status");
|
||||
setImapStatus(res.data);
|
||||
} catch {
|
||||
setImapStatus({ connected: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadGmailStatus();
|
||||
void loadMicrosoftStatus();
|
||||
void loadImapStatus();
|
||||
}, [loadGmailStatus, loadMicrosoftStatus, loadImapStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data as { source?: string; status?: string; message?: string };
|
||||
if (data?.source === "jobtracker-gmail-oauth") {
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || "Gmail connected.", "success");
|
||||
void loadGmailStatus();
|
||||
} else {
|
||||
toast(data.message || "Gmail connection failed.", "error");
|
||||
}
|
||||
} else if (data?.source === "jobtracker-microsoft-oauth") {
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || "Outlook connected.", "success");
|
||||
void loadMicrosoftStatus();
|
||||
} else {
|
||||
toast(data.message || "Outlook connection failed.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [loadGmailStatus, loadMicrosoftStatus, toast]);
|
||||
|
||||
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
|
||||
try {
|
||||
const res = await api.get<{ url: string }>(connectUrlPath);
|
||||
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) toast("Your browser blocked the connect popup. Allow popups and try again.", "error");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, `Failed to start ${providerLabel} connection.`), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async (path: string, reload: () => Promise<void>, providerLabel: string) => {
|
||||
try {
|
||||
await api.delete(path);
|
||||
await reload();
|
||||
toast(`${providerLabel} disconnected.`, "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, `Failed to disconnect ${providerLabel}.`), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const connectImap = async () => {
|
||||
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
|
||||
toast("Host, username, and password are required.", "error");
|
||||
return;
|
||||
}
|
||||
setImapConnecting(true);
|
||||
try {
|
||||
await api.post("/imap/connect", imapForm);
|
||||
setImapForm((prev) => ({ ...prev, password: "" }));
|
||||
await loadImapStatus();
|
||||
toast("IMAP account connected.", "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to connect IMAP account."), "error");
|
||||
} finally {
|
||||
setImapConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>Linked email accounts</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||
Connect a mailbox so recruiter correspondence can be linked to jobs automatically.
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<ProviderRow
|
||||
label="Gmail"
|
||||
connected={Boolean(gmailStatus?.connected)}
|
||||
address={gmailStatus?.gmailAddress ?? null}
|
||||
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
||||
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ProviderRow
|
||||
label="Outlook / Microsoft 365"
|
||||
connected={Boolean(microsoftStatus?.connected)}
|
||||
address={microsoftStatus?.mailAddress ?? null}
|
||||
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
||||
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<ProviderRow
|
||||
label="Other (IMAP)"
|
||||
connected={Boolean(imapStatus?.connected)}
|
||||
address={imapStatus?.username ?? null}
|
||||
onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")}
|
||||
/>
|
||||
{!imapStatus?.connected && (
|
||||
<Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="IMAP host"
|
||||
placeholder="imap.example.com"
|
||||
value={imapForm.host}
|
||||
onChange={(e) => setImapForm((prev) => ({ ...prev, host: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Port"
|
||||
type="number"
|
||||
value={imapForm.port}
|
||||
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Username"
|
||||
value={imapForm.username}
|
||||
onChange={(e) => setImapForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Password"
|
||||
type="password"
|
||||
value={imapForm.password}
|
||||
onChange={(e) => setImapForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
control={<Checkbox checked={imapForm.useSsl} onChange={(e) => setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />}
|
||||
label="Use SSL/TLS"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={connectImap}
|
||||
disabled={imapConnecting}
|
||||
sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
|
||||
>
|
||||
{imapConnecting ? "Connecting…" : "Connect IMAP account"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
label,
|
||||
connected,
|
||||
address,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
label: string;
|
||||
connected: boolean;
|
||||
address: string | null;
|
||||
onConnect?: () => void;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{label}</Typography>
|
||||
{connected ? (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<CheckCircleIcon fontSize="small" />}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
label={address || "Connected"}
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>Not connected</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{connected ? (
|
||||
<Button size="small" variant="outlined" color="error" onClick={onDisconnect}>Disconnect</Button>
|
||||
) : (
|
||||
onConnect && <Button size="small" variant="outlined" onClick={onConnect}>Connect</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
||||
import { PublicClientApplication } from "@azure/msal-browser";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "microsoft" | "external";
|
||||
email?: string;
|
||||
userName?: string;
|
||||
displayName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
microsoftLink?: {
|
||||
linked: boolean;
|
||||
email?: string | null;
|
||||
linkedAt?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
let msalInstance: PublicClientApplication | null = null;
|
||||
function getMsalInstance(clientId: string): PublicClientApplication {
|
||||
msalInstance ??= new PublicClientApplication({
|
||||
auth: { clientId, authority: "https://login.microsoftonline.com/common", redirectUri: window.location.origin },
|
||||
});
|
||||
return msalInstance;
|
||||
}
|
||||
|
||||
export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
|
||||
const signedIn = Boolean(me?.provider);
|
||||
const actionLabel = !signedIn
|
||||
? t("continueWithMicrosoft")
|
||||
: me?.provider === "local" && !me?.microsoftLink?.linked
|
||||
? t("linkWithMicrosoft")
|
||||
: t("signInWithMicrosoft");
|
||||
|
||||
async function refreshMe() {
|
||||
try {
|
||||
const res = await api.get<MeResponse>("/auth/me");
|
||||
setMe(res.data);
|
||||
} catch {
|
||||
setMe(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onAuthChanged = () => { void refreshMe(); };
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, []);
|
||||
|
||||
async function handleSignIn() {
|
||||
if (!clientId) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
const msal = getMsalInstance(clientId);
|
||||
await msal.initialize();
|
||||
const result = await msal.loginPopup({ scopes: ["openid", "profile", "email"] });
|
||||
const idToken = result.idToken;
|
||||
if (!idToken) throw new Error(t("microsoftAuthFailed"));
|
||||
|
||||
if (me?.provider === "local") {
|
||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
|
||||
toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success");
|
||||
await refreshMe();
|
||||
} else {
|
||||
await api.post("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
|
||||
window.dispatchEvent(new Event("auth-changed"));
|
||||
toast(t("microsoftSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("microsoftAccountTitle")}
|
||||
</Typography>
|
||||
|
||||
{!clientId && (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("microsoftSetupHint")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{clientId && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={me?.microsoftLink?.linked ? t("microsoftLinked") : t("microsoftAvailableToLink")} color={me?.microsoftLink?.linked ? "success" : "default"} variant={me?.microsoftLink?.linked ? "filled" : "outlined"} />
|
||||
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString() })} /> : null}
|
||||
</Box>
|
||||
|
||||
{!signedIn ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("microsoftSignInHint")}
|
||||
</Typography>
|
||||
) : me?.provider === "local" ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{me.microsoftLink?.linked
|
||||
? t("microsoftLinkedTo", { email: me.microsoftLink.email || t("microsoftLinkedToYourAccount") })
|
||||
: t("microsoftBindHint")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("microsoftExchangeHint")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
|
||||
{actionLabel}
|
||||
</Typography>
|
||||
<Button variant="outlined" disabled={working} onClick={() => void handleSignIn()}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
{signedIn ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
void api.post("/auth/logout").catch(() => undefined).finally(() => {
|
||||
clearAuthClientState();
|
||||
setMe(null);
|
||||
toast(t("signedOut"), "info");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{me?.provider === "local" && me.microsoftLink?.linked ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={working}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.delete("/auth/microsoft/link");
|
||||
toast(t("microsoftUnlinked"), "info");
|
||||
await refreshMe();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("microsoftUnlinkFailed");
|
||||
toast(String(msg), "error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("unlinkMicrosoft")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{signedIn && me?.email ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("signedInAs", { name: signedInName })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { JobTableColumns } from "./JobTable";
|
||||
import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import EmailProviderConnections from "./EmailProviderConnections";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import QuickCaptureCard from "./QuickCaptureCard";
|
||||
@@ -338,6 +339,9 @@ export default function SettingsView({
|
||||
<TabPanel value={tab} index={3}>
|
||||
<AuthStatusCard />
|
||||
<GoogleAuthCard />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<EmailProviderConnections />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={4}>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { api } from "./api";
|
||||
import EmailProviderConnections from "./components/EmailProviderConnections";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<EmailProviderConnections />
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("EmailProviderConnections", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => {
|
||||
mockedApi.get.mockImplementation((path: string) => {
|
||||
if (path === "/gmail/status") return Promise.resolve({ data: { connected: true, gmailAddress: "me@gmail.test" } });
|
||||
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
|
||||
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
|
||||
return Promise.reject(new Error("unexpected path"));
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText("me@gmail.test")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("IMAP host")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Not connected").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("submits IMAP connect form and reloads status on success", async () => {
|
||||
mockedApi.get.mockImplementation((path: string) => {
|
||||
if (path === "/gmail/status") return Promise.resolve({ data: { connected: false } });
|
||||
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
|
||||
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
|
||||
return Promise.reject(new Error("unexpected path"));
|
||||
});
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { username: "user@example.test" } });
|
||||
|
||||
renderComponent();
|
||||
await screen.findByLabelText("IMAP host");
|
||||
|
||||
await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test");
|
||||
await userEvent.type(screen.getByLabelText("Username"), "user@example.test");
|
||||
await userEvent.type(screen.getByLabelText("Password"), "secret");
|
||||
await userEvent.click(screen.getByRole("button", { name: /connect imap account/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({
|
||||
host: "imap.example.test",
|
||||
username: "user@example.test",
|
||||
password: "secret",
|
||||
})));
|
||||
});
|
||||
});
|
||||
@@ -628,6 +628,26 @@ export const translations = {
|
||||
googleScriptLoadFailed: "Google auth script failed to load.",
|
||||
googleUnlinked: "Google account unlinked.",
|
||||
googleUnlinkFailed: "Failed to unlink Google account.",
|
||||
microsoftAccountTitle: "Microsoft account",
|
||||
microsoftSetupHint: "Set `REACT_APP_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
|
||||
microsoftLinked: "Linked",
|
||||
microsoftAvailableToLink: "Available to link",
|
||||
microsoftLinkedDate: "Linked {date}",
|
||||
microsoftSignInHint: "Sign in with a Microsoft account that has already been linked to your Jobbjakt user.",
|
||||
continueWithMicrosoft: "Continue with Microsoft",
|
||||
signInWithMicrosoft: "Sign in with Microsoft",
|
||||
linkWithMicrosoft: "Link with Microsoft",
|
||||
microsoftLinkedTo: "Linked to {email}.",
|
||||
microsoftLinkedToYourAccount: "Linked to your Microsoft account.",
|
||||
microsoftBindHint: "Bind a Microsoft account to this user so you can sign in with Microsoft and still keep your normal app roles and data.",
|
||||
microsoftExchangeHint: "Exchange your Microsoft sign-in for a normal Jobbjakt session.",
|
||||
microsoftSignedIn: "Signed in with Microsoft.",
|
||||
microsoftLinkedSuccess: "Microsoft account linked.",
|
||||
microsoftLinkedSuccessWithEmail: "Linked Microsoft account {email}.",
|
||||
microsoftAuthFailed: "Microsoft authentication failed.",
|
||||
microsoftUnlinked: "Microsoft account unlinked.",
|
||||
microsoftUnlinkFailed: "Failed to unlink Microsoft account.",
|
||||
unlinkMicrosoft: "Unlink Microsoft",
|
||||
signedOut: "Signed out.",
|
||||
signedInAs: "Signed in as {name}.",
|
||||
unlinkGoogle: "Unlink Google",
|
||||
@@ -663,6 +683,7 @@ export const translations = {
|
||||
authOptional: "Authentication is optional in this environment.",
|
||||
emailAndPassword: "Email & password",
|
||||
google: "Google",
|
||||
microsoft: "Microsoft",
|
||||
createAccount: "Create account",
|
||||
signedIn: "Signed in.",
|
||||
rememberMe: "Remember me",
|
||||
@@ -1572,6 +1593,26 @@ export const translations = {
|
||||
googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.",
|
||||
googleUnlinked: "Google-konto koblet fra.",
|
||||
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
||||
microsoftAccountTitle: "Microsoft-konto",
|
||||
microsoftSetupHint: "Sett `REACT_APP_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
|
||||
microsoftLinked: "Koblet",
|
||||
microsoftAvailableToLink: "Tilgjengelig for kobling",
|
||||
microsoftLinkedDate: "Koblet {date}",
|
||||
microsoftSignInHint: "Logg inn med en Microsoft-konto som allerede er koblet til Jobbjakt-brukeren din.",
|
||||
continueWithMicrosoft: "Fortsett med Microsoft",
|
||||
signInWithMicrosoft: "Logg inn med Microsoft",
|
||||
linkWithMicrosoft: "Koble til med Microsoft",
|
||||
microsoftLinkedTo: "Koblet til {email}.",
|
||||
microsoftLinkedToYourAccount: "Koblet til Microsoft-kontoen din.",
|
||||
microsoftBindHint: "Koble en Microsoft-konto til denne brukeren slik at du kan logge inn med Microsoft og fortsatt beholde vanlige approller og data.",
|
||||
microsoftExchangeHint: "Bytt Microsoft-innloggingen din mot en vanlig Jobbjakt-økt.",
|
||||
microsoftSignedIn: "Logget inn med Microsoft.",
|
||||
microsoftLinkedSuccess: "Microsoft-konto koblet.",
|
||||
microsoftLinkedSuccessWithEmail: "Koblet Microsoft-konto {email}.",
|
||||
microsoftAuthFailed: "Microsoft-autentisering mislyktes.",
|
||||
microsoftUnlinked: "Microsoft-konto koblet fra.",
|
||||
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
|
||||
unlinkMicrosoft: "Koble fra Microsoft",
|
||||
signedOut: "Logget ut.",
|
||||
signedInAs: "Logget inn som {name}.",
|
||||
unlinkGoogle: "Koble fra Google",
|
||||
@@ -1607,6 +1648,7 @@ export const translations = {
|
||||
authOptional: "Autentisering er valgfri i dette miljøet.",
|
||||
emailAndPassword: "E-post og passord",
|
||||
google: "Google",
|
||||
microsoft: "Microsoft",
|
||||
createAccount: "Opprett konto",
|
||||
signedIn: "Logget inn.",
|
||||
rememberMe: "Husk meg",
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type AuthConfig = {
|
||||
requireAuth: boolean;
|
||||
googleEnabled: boolean;
|
||||
microsoftEnabled: boolean;
|
||||
localEnabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
};
|
||||
@@ -81,6 +83,7 @@ export default function LoginPage() {
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label={t("emailAndPassword")} />
|
||||
<Tab label={t("google")} />
|
||||
<Tab label={t("microsoft")} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
@@ -123,6 +126,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -562,6 +563,7 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
|
||||
<GoogleAuthCard />
|
||||
<MicrosoftAuthCard />
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
|
||||
@@ -29,12 +29,13 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('./components/GoogleAuthCard', () => () => null);
|
||||
jest.mock('./components/MicrosoftAuthCard', () => () => null);
|
||||
|
||||
beforeEach(() => {
|
||||
const { api } = require('./api');
|
||||
api.get.mockImplementation((url: string) => {
|
||||
if (url === '/auth/config') {
|
||||
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, localEnabled: true, allowRegistration: false } });
|
||||
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } });
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } });
|
||||
|
||||
@@ -384,6 +384,35 @@ export interface GmailStatus {
|
||||
lastSyncError?: string | null;
|
||||
}
|
||||
|
||||
export interface MicrosoftGraphStatus {
|
||||
connected: boolean;
|
||||
mailAddress?: string | null;
|
||||
connectedAt?: string;
|
||||
lastSyncedAt?: string;
|
||||
lastSyncAttemptedAt?: string;
|
||||
lastSyncSucceededAt?: string;
|
||||
lastSyncMode?: string | null;
|
||||
lastSyncSource?: string | null;
|
||||
lastSyncStatus?: string | null;
|
||||
lastSyncError?: string | null;
|
||||
}
|
||||
|
||||
export interface ImapStatus {
|
||||
connected: boolean;
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
useSsl?: boolean | null;
|
||||
username?: string | null;
|
||||
connectedAt?: string;
|
||||
lastSyncedAt?: string;
|
||||
lastSyncAttemptedAt?: string;
|
||||
lastSyncSucceededAt?: string;
|
||||
lastSyncMode?: string | null;
|
||||
lastSyncSource?: string | null;
|
||||
lastSyncStatus?: string | null;
|
||||
lastSyncError?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailManualSyncResult {
|
||||
queriesRun: number;
|
||||
candidateThreadCount: number;
|
||||
|
||||
Reference in New Issue
Block a user