Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbb15804a3 | |||
| acf60c2a07 | |||
| 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
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
|
||||
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
|
||||
public DbSet<MicrosoftGraphConnection> MicrosoftGraphConnections => Set<MicrosoftGraphConnection>();
|
||||
public DbSet<ImapConnection> ImapConnections => Set<ImapConnection>();
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
||||
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
||||
|
||||
@@ -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() }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ImapControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Status_returns_connection_fields_for_connected_account()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapConnection
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
Host = "imap.example.test",
|
||||
Port = 993,
|
||||
UseSsl = true,
|
||||
Username = "user@example.test",
|
||||
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
LastSyncStatus = "ok"
|
||||
});
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
|
||||
Assert.True(payload.Connected);
|
||||
Assert.Equal("imap.example.test", payload.Host);
|
||||
Assert.Equal(993, payload.Port);
|
||||
Assert.Equal("user@example.test", payload.Username);
|
||||
Assert.Equal("ok", payload.LastSyncStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_reports_not_connected_when_no_connection_exists()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ImapConnection?)null);
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
|
||||
Assert.False(payload.Connected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", 993, "user", "pass", "Host is required.")]
|
||||
[InlineData("imap.example.test", 0, "user", "pass", "Valid port is required.")]
|
||||
[InlineData("imap.example.test", 993, "", "pass", "Username is required.")]
|
||||
[InlineData("imap.example.test", 993, "user", "", "Password is required.")]
|
||||
public async Task Connect_rejects_missing_fields(string host, int port, string username, string password, string expectedError)
|
||||
{
|
||||
var imap = new Mock<IImapService>(MockBehavior.Strict);
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest(host, port, true, username, password), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Equal(expectedError, badRequest.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_returns_bad_request_when_service_rejects_credentials()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user", "wrong", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("IMAP authentication failed: bad credentials"));
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user", "wrong"), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Contains("authentication failed", (string)badRequest.Value!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_succeeds_and_returns_username()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user@example.test", "correct", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapConnectResult("user@example.test"));
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user@example.test", "correct"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var username = ok.Value!.GetType().GetProperty("username")!.GetValue(ok.Value) as string;
|
||||
Assert.Equal("user@example.test", username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_calls_service_for_authenticated_user()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Disconnect(CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
imap.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static ImapController CreateController(IImapService imap, string userId)
|
||||
{
|
||||
return new ImapController(imap)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ImapProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProviderKey_is_imap()
|
||||
{
|
||||
var provider = new ImapProvider(Mock.Of<IImapService>());
|
||||
Assert.Equal("imap", provider.ProviderKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_maps_username_onto_neutral_shape()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new JobTrackerApi.Models.ImapConnection { OwnerUserId = "user-1", Username = "user@example.test" });
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(connection);
|
||||
Assert.Equal("imap", connection!.ProviderKey);
|
||||
Assert.Equal("user@example.test", connection.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_returns_null_when_not_connected()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((JobTrackerApi.Models.ImapConnection?)null);
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.Null(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_maps_thread_key_onto_neutral_thread_id()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ImapMessageSummary>
|
||||
{
|
||||
new("42", "root-msg-id@example.test", "Interview", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet")
|
||||
});
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None);
|
||||
|
||||
var summary = Assert.Single(results);
|
||||
Assert.Equal("42", summary.Id);
|
||||
Assert.Equal("root-msg-id@example.test", summary.ThreadId);
|
||||
Assert.Equal("Interview", summary.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessageAsync_maps_content_id_onto_neutral_external_attachment_id()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetMessageAsync("user-1", "42", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapMessageDetail(
|
||||
"42", "root-msg-id@example.test", "Offer", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet",
|
||||
"body text", "<p>body</p>", new List<string>(),
|
||||
new List<ImapMessageAttachment> { new("resume.pdf", "application/pdf", 1024, "cid-1", false) }));
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var detail = await provider.GetMessageAsync("user-1", "42", CancellationToken.None);
|
||||
|
||||
Assert.Equal("root-msg-id@example.test", detail.ThreadId);
|
||||
var attachment = Assert.Single(detail.Attachments);
|
||||
Assert.Equal("resume.pdf", attachment.FileName);
|
||||
Assert.Equal("cid-1", attachment.ExternalAttachmentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.IO;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Regression coverage for the SSRF guard in ImapService: an authenticated user's IMAP "connect"
|
||||
// target must not be usable to probe loopback/RFC1918/link-local/cloud-metadata addresses.
|
||||
public sealed class ImapServiceSsrfGuardTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("localhost")]
|
||||
[InlineData("10.0.0.5")]
|
||||
[InlineData("172.16.0.5")]
|
||||
[InlineData("192.168.1.5")]
|
||||
[InlineData("169.254.169.254")] // cloud metadata endpoint
|
||||
public async Task ConnectAsync_rejects_internal_and_metadata_hosts(string host)
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ConnectAsync("user-1", host, 993, true, "user", "password", CancellationToken.None));
|
||||
|
||||
// Message must not leak connect-vs-auth distinction (that's the oracle this guard closes).
|
||||
Assert.DoesNotContain("resolve", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("reachable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ConnectAsync("user-1", "this-host-does-not-exist.invalid", 993, true, "user", "password", CancellationToken.None));
|
||||
|
||||
Assert.Equal("Could not connect to that IMAP server with the given credentials. Check host, port, and password.", ex.Message);
|
||||
}
|
||||
|
||||
private static ImapService CreateService()
|
||||
{
|
||||
var db = TestHostFactory.CreateInMemoryDb();
|
||||
var protectionProvider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}")));
|
||||
return new ImapService(db, protectionProvider);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1012,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))
|
||||
@@ -1065,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
|
||||
@@ -1075,7 +976,6 @@ public sealed class GmailController : ControllerBase
|
||||
OwnerUserId = ownerUserId,
|
||||
ThreadId = threadId,
|
||||
};
|
||||
decisions.Add(existing);
|
||||
_db.GmailReviewDecisions.Add(existing);
|
||||
}
|
||||
|
||||
@@ -1085,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")
|
||||
@@ -1167,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>";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic IMAP connection lifecycle for mailboxes with no dedicated OAuth provider. Unlike
|
||||
/// Gmail/Microsoft, there's no OAuth redirect — the caller submits host/username/password once,
|
||||
/// <see cref="IImapService"/> verifies them by connecting, then encrypts and stores them.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/imap")]
|
||||
[Authorize]
|
||||
public sealed class ImapController : ControllerBase
|
||||
{
|
||||
private readonly IImapService _imap;
|
||||
|
||||
public ImapController(IImapService imap)
|
||||
{
|
||||
_imap = imap;
|
||||
}
|
||||
|
||||
public sealed record ImapConnectRequest(string Host, int Port, bool UseSsl, string Username, string Password);
|
||||
|
||||
public sealed record ImapConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? Host,
|
||||
int? Port,
|
||||
bool? UseSsl,
|
||||
string? Username,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<ImapConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return Ok(new ImapConnectionStatusDto(
|
||||
connection is not null,
|
||||
connection?.Host,
|
||||
connection?.Port,
|
||||
connection?.UseSsl,
|
||||
connection?.Username,
|
||||
connection?.ConnectedAt,
|
||||
connection?.LastSyncedAt,
|
||||
connection?.LastSyncAttemptedAt,
|
||||
connection?.LastSyncSucceededAt,
|
||||
connection?.LastSyncMode,
|
||||
connection?.LastSyncSource,
|
||||
connection?.LastSyncStatus,
|
||||
connection?.LastSyncError));
|
||||
}
|
||||
|
||||
[HttpPost("connect")]
|
||||
public async Task<IActionResult> Connect([FromBody] ImapConnectRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Host)) return BadRequest("Host is required.");
|
||||
if (request.Port <= 0 || request.Port > 65535) return BadRequest("Valid port is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.Username)) return BadRequest("Username is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.Password)) return BadRequest("Password is required.");
|
||||
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
try
|
||||
{
|
||||
var result = await _imap.ConnectAsync(ownerUserId, request.Host, request.Port, request.UseSsl, request.Username, request.Password, cancellationToken);
|
||||
return Ok(new { username = result.Username });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("connection")]
|
||||
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
await _imap.DisconnectAsync(ownerUserId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private string GetRequiredOwnerUserId()
|
||||
{
|
||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||
?? throw new InvalidOperationException("Authenticated user id is missing.");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -166,10 +166,12 @@ builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
|
||||
builder.Services.AddScoped<IImapService, ImapService>();
|
||||
|
||||
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph today; IMAP / manual next).
|
||||
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next).
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>();
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.ImapProvider>();
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
namespace JobTrackerApi.Services.EmailProviders
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic IMAP implementation of <see cref="IEmailProvider"/> for mailboxes with no
|
||||
/// dedicated OAuth provider. Adapts <see cref="IImapService"/> to the provider-neutral
|
||||
/// contract, mapping IMAP DTOs to the neutral shapes.
|
||||
/// </summary>
|
||||
public sealed class ImapProvider : IEmailProvider
|
||||
{
|
||||
private readonly IImapService _imap;
|
||||
|
||||
public ImapProvider(IImapService imap)
|
||||
{
|
||||
_imap = imap;
|
||||
}
|
||||
|
||||
public string ProviderKey => "imap";
|
||||
|
||||
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return connection is null ? null : new EmailConnectionInfo("imap", connection.Username ?? "");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _imap.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
|
||||
return messages.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _imap.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
return messages.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = await _imap.GetMessageAsync(ownerUserId, messageId, cancellationToken);
|
||||
var attachments = detail.Attachments
|
||||
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.ContentId, a.Inline))
|
||||
.ToList();
|
||||
|
||||
return new EmailMessageDetail(
|
||||
detail.Id,
|
||||
detail.ThreadKey,
|
||||
detail.Subject,
|
||||
detail.From,
|
||||
detail.To,
|
||||
detail.Date,
|
||||
detail.Snippet,
|
||||
detail.BodyText,
|
||||
detail.BodyHtml,
|
||||
detail.Labels,
|
||||
attachments);
|
||||
}
|
||||
|
||||
private static EmailMessageSummary ToSummary(ImapMessageSummary m)
|
||||
=> new(m.Id, m.ThreadKey, m.Subject, m.From, m.To, m.Date, m.Snippet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MimeKit;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface IImapService
|
||||
{
|
||||
Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken);
|
||||
Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken);
|
||||
Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ImapConnectResult(string Username);
|
||||
public sealed record ImapMessageSummary(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record ImapMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? ContentId, bool Inline);
|
||||
public sealed record ImapMessageDetail(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<ImapMessageAttachment> Attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Generic IMAP mail access for "any provider not explicitly supported" (Gmail/Microsoft have
|
||||
/// their own OAuth-based providers). Auth is direct host/username/password rather than OAuth —
|
||||
/// there's no connect-url/callback dance, the caller submits credentials once and they're
|
||||
/// encrypted at rest the same way Gmail/Microsoft's refresh tokens are.
|
||||
///
|
||||
/// ponytail: scoped to INBOX only, and "thread" is approximated from the References/In-Reply-To
|
||||
/// headers (the root Message-Id) rather than the server-side IMAP THREAD extension, which not
|
||||
/// every provider implements. Good enough for "show me the other messages in this conversation";
|
||||
/// upgrade to THREAD/SORT if a target provider needs cross-folder or extension-grade threading.
|
||||
/// External message ids are "{UID}" scoped to INBOX under the connection's current UIDVALIDITY —
|
||||
/// they are not stable across a UIDVALIDITY change (rare: a full mailbox reset on the server).
|
||||
/// </summary>
|
||||
public sealed class ImapService : IImapService
|
||||
{
|
||||
private const int ThreadScanWindow = 200;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
|
||||
public ImapService(JobTrackerContext db, IDataProtectionProvider protectionProvider)
|
||||
{
|
||||
_db = db;
|
||||
_protector = protectionProvider.CreateProtector("imap-credentials-v1");
|
||||
}
|
||||
|
||||
public async Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
host = host.Trim();
|
||||
username = username.Trim();
|
||||
if (host.Length == 0) throw new InvalidOperationException("IMAP host is required.");
|
||||
if (username.Length == 0) throw new InvalidOperationException("IMAP username is required.");
|
||||
if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("IMAP password is required.");
|
||||
|
||||
// Verify the credentials actually work before persisting them. Failure detail is
|
||||
// intentionally generic (not the raw MailKit exception) so a "connect" attempt can't be
|
||||
// used as a distinguishable oracle to fingerprint what's listening on a given host:port.
|
||||
using (var client = new ImapClient())
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureHostIsExternalAsync(host, cancellationToken);
|
||||
await client.ConnectAsync(host, port, useSsl, cancellationToken);
|
||||
await client.AuthenticateAsync(username, password, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new InvalidOperationException("Could not connect to that IMAP server with the given credentials. Check host, port, and password.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client.IsConnected)
|
||||
{
|
||||
await client.DisconnectAsync(true, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new ImapConnection { OwnerUserId = ownerUserId };
|
||||
_db.ImapConnections.Add(existing);
|
||||
}
|
||||
|
||||
existing.Host = host;
|
||||
existing.Port = port;
|
||||
existing.UseSsl = useSsl;
|
||||
existing.Username = username;
|
||||
existing.EncryptedPassword = _protector.Protect(password);
|
||||
existing.ConnectedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncStatus = "connected";
|
||||
existing.LastSyncSource = "connect";
|
||||
existing.LastSyncMode = "connect";
|
||||
existing.LastSyncError = null;
|
||||
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new ImapConnectResult(existing.Username);
|
||||
}
|
||||
|
||||
public Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
=> _db.ImapConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
|
||||
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null) return;
|
||||
_db.ImapConnections.Remove(existing);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResults = Math.Clamp(maxResults, 1, 25);
|
||||
try
|
||||
{
|
||||
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
|
||||
var searchQuery = string.IsNullOrWhiteSpace(query)
|
||||
? SearchQuery.All
|
||||
: SearchQuery.SubjectContains(query.Trim()).Or(SearchQuery.FromContains(query.Trim())).Or(SearchQuery.BodyContains(query.Trim()));
|
||||
|
||||
var uids = await client.Inbox.SearchAsync(searchQuery, cancellationToken);
|
||||
var window = uids.OrderByDescending(u => u.Id).Take(maxResults).ToList();
|
||||
var summaries = await FetchSummariesAsync(client, window, cancellationToken);
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
||||
return summaries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(threadKey))
|
||||
{
|
||||
return Array.Empty<ImapMessageSummary>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
|
||||
var recentUids = (await client.Inbox.SearchAsync(SearchQuery.All, cancellationToken))
|
||||
.OrderByDescending(u => u.Id)
|
||||
.Take(ThreadScanWindow)
|
||||
.ToList();
|
||||
|
||||
var items = await client.Inbox.FetchAsync(recentUids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
|
||||
var matches = items.Where(item => ComputeThreadKey(item) == threadKey.Trim()).ToList();
|
||||
var summaries = matches.Select(ToSummary).OrderBy(s => s.Date).ToList();
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", true, null, cancellationToken);
|
||||
return summaries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
|
||||
var uid = ParseUid(messageId);
|
||||
|
||||
var summaryItems = await client.Inbox.FetchAsync(new[] { uid }, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
|
||||
var summary = summaryItems.FirstOrDefault() ?? throw new InvalidOperationException($"IMAP message {messageId} was not found.");
|
||||
|
||||
var mime = await client.Inbox.GetMessageAsync(uid, cancellationToken);
|
||||
var bodyText = mime.TextBody ?? (mime.HtmlBody is null ? "" : StripHtml(mime.HtmlBody));
|
||||
var attachments = mime.Attachments.Select(a => new ImapMessageAttachment(
|
||||
a.ContentDisposition?.FileName ?? a.ContentType?.Name,
|
||||
a.ContentType?.MimeType,
|
||||
a is MimePart part ? part.Content?.Stream?.Length : null,
|
||||
a.ContentId,
|
||||
a.IsAttachment == false
|
||||
)).ToList();
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", true, null, cancellationToken);
|
||||
return new ImapMessageDetail(
|
||||
messageId,
|
||||
ComputeThreadKey(summary),
|
||||
summary.Envelope?.Subject ?? "",
|
||||
FormatAddresses(summary.Envelope?.From),
|
||||
FormatAddresses(summary.Envelope?.To),
|
||||
summary.Envelope?.Date,
|
||||
bodyText.Length > 200 ? bodyText[..200] : bodyText,
|
||||
bodyText.Trim(),
|
||||
mime.HtmlBody,
|
||||
Array.Empty<string>(),
|
||||
attachments);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<ImapMessageSummary>> FetchSummariesAsync(ImapClient client, IList<UniqueId> uids, CancellationToken cancellationToken)
|
||||
{
|
||||
if (uids.Count == 0) return Array.Empty<ImapMessageSummary>();
|
||||
var items = await client.Inbox.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
|
||||
return items.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
private static ImapMessageSummary ToSummary(IMessageSummary item) => new(
|
||||
item.UniqueId.Id.ToString(),
|
||||
ComputeThreadKey(item),
|
||||
item.Envelope?.Subject ?? "",
|
||||
FormatAddresses(item.Envelope?.From),
|
||||
FormatAddresses(item.Envelope?.To),
|
||||
item.Envelope?.Date,
|
||||
"");
|
||||
|
||||
// The root Message-Id of the References chain, or this message's own Message-Id if it
|
||||
// starts no chain — a stand-in "thread id" that works without the IMAP THREAD extension.
|
||||
private static string ComputeThreadKey(IMessageSummary item)
|
||||
{
|
||||
if (item.References is { Count: > 0 })
|
||||
{
|
||||
return item.References[0];
|
||||
}
|
||||
return item.Envelope?.MessageId ?? item.UniqueId.Id.ToString();
|
||||
}
|
||||
|
||||
private static string FormatAddresses(InternetAddressList? list)
|
||||
=> list is null ? "" : string.Join(", ", list.Mailboxes.Select(m => m.Address));
|
||||
|
||||
private static string StripHtml(string html)
|
||||
=> System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();
|
||||
|
||||
private static UniqueId ParseUid(string messageId)
|
||||
=> uint.TryParse(messageId, out var id) ? new UniqueId(id) : throw new InvalidOperationException($"Invalid IMAP message id: {messageId}");
|
||||
|
||||
private async Task<ImapClient> OpenInboxAsync(string ownerUserId, bool writable, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("IMAP is not connected for this account.");
|
||||
|
||||
string password;
|
||||
try
|
||||
{
|
||||
password = _protector.Unprotect(connection.EncryptedPassword);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
throw new InvalidOperationException("Your stored IMAP connection can no longer be decrypted after a server key change. Disconnect and reconnect IMAP.");
|
||||
}
|
||||
|
||||
await EnsureHostIsExternalAsync(connection.Host, cancellationToken);
|
||||
var client = new ImapClient();
|
||||
await client.ConnectAsync(connection.Host, connection.Port, connection.UseSsl, cancellationToken);
|
||||
await client.AuthenticateAsync(connection.Username, password, cancellationToken);
|
||||
await client.Inbox.OpenAsync(writable ? FolderAccess.ReadWrite : FolderAccess.ReadOnly, cancellationToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
// SSRF guard: a user-supplied IMAP host resolves to an IP the server then opens a socket to.
|
||||
// Without this check an authenticated user could point "their mailbox" at loopback, RFC1918/
|
||||
// link-local ranges, or the cloud metadata address to probe internal infrastructure. Re-run on
|
||||
// every connect (not just the initial one) so a DNS record that resolves externally at connect
|
||||
// time can't be rebound internally for a later reconnect.
|
||||
private static async Task EnsureHostIsExternalAsync(string host, CancellationToken cancellationToken)
|
||||
{
|
||||
IPAddress[] addresses;
|
||||
try
|
||||
{
|
||||
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
throw new InvalidOperationException("Could not resolve that IMAP host.");
|
||||
}
|
||||
|
||||
if (addresses.Length == 0 || addresses.Any(IsInternalAddress))
|
||||
{
|
||||
throw new InvalidOperationException("That IMAP host is not reachable.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInternalAddress(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||
|
||||
if (IPAddress.IsLoopback(address)) return true;
|
||||
if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) return true;
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (bytes[0] == 10) return true; // 10.0.0.0/8
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16
|
||||
if (bytes[0] == 169 && bytes[1] == 254) return true; // 169.254.0.0/16 (incl. cloud metadata)
|
||||
if (bytes[0] == 127) return true; // 127.0.0.0/8
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal) return true;
|
||||
var bytes = address.GetAddressBytes();
|
||||
if ((bytes[0] & 0xFE) == 0xFC) return true; // fc00::/7 (unique local)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // unknown address family: fail closed
|
||||
}
|
||||
|
||||
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null) return;
|
||||
|
||||
connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
||||
connection.LastSyncMode = mode;
|
||||
connection.LastSyncSource = source;
|
||||
connection.LastSyncStatus = succeeded ? "ok" : "error";
|
||||
connection.LastSyncError = succeeded ? null : error;
|
||||
if (succeeded)
|
||||
{
|
||||
connection.LastSyncedAt = DateTimeOffset.UtcNow;
|
||||
connection.LastSyncSucceededAt = connection.LastSyncedAt;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -130,50 +310,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,
|
||||
@@ -386,6 +522,31 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");""");
|
||||
}
|
||||
|
||||
static void EnsureImapConnectionsTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "ImapConnections" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"Host" TEXT NOT NULL,
|
||||
"Port" INTEGER NOT NULL,
|
||||
"UseSsl" INTEGER NOT NULL,
|
||||
"Username" TEXT NOT NULL,
|
||||
"EncryptedPassword" TEXT NOT NULL,
|
||||
"ConnectedAt" TEXT NOT NULL,
|
||||
"LastSyncedAt" TEXT NULL,
|
||||
"LastSyncAttemptedAt" TEXT NULL,
|
||||
"LastSyncSucceededAt" TEXT NULL,
|
||||
"LastSyncMode" TEXT NULL,
|
||||
"LastSyncSource" TEXT NULL,
|
||||
"LastSyncStatus" TEXT NULL,
|
||||
"LastSyncError" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ImapConnections_OwnerUserId" ON "ImapConnections" ("OwnerUserId");""");
|
||||
}
|
||||
|
||||
static void EnsureCvTables(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
@@ -455,6 +616,7 @@ public static class StartupInitializationExtensions
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
EnsureCvTables(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
@@ -504,39 +666,10 @@ public 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, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
|
||||
// Backfill: historically the only import source was Gmail (rows with an
|
||||
// ExternalThreadId); everything else was hand-entered. Idempotent — only touches
|
||||
// rows the app hasn't tagged yet.
|
||||
Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
|
||||
Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
|
||||
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
||||
// 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
|
||||
@@ -560,18 +693,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();
|
||||
@@ -584,28 +705,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)
|
||||
{
|
||||
@@ -640,67 +739,15 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
||||
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, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
|
||||
using (var backfillGmail = conn.CreateCommand())
|
||||
{
|
||||
backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
|
||||
backfillGmail.ExecuteNonQuery();
|
||||
}
|
||||
using (var backfillManual = conn.CreateCommand())
|
||||
{
|
||||
backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
|
||||
backfillManual.ExecuteNonQuery();
|
||||
}
|
||||
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
|
||||
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
|
||||
// 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;");
|
||||
@@ -868,6 +915,30 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "ImapConnections"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ImapConnections` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`Host` varchar(255) NOT NULL,
|
||||
`Port` int NOT NULL,
|
||||
`UseSsl` tinyint(1) NOT NULL,
|
||||
`Username` varchar(255) NOT NULL,
|
||||
`EncryptedPassword` longtext NOT NULL,
|
||||
`ConnectedAt` datetime(6) NOT NULL,
|
||||
`LastSyncedAt` datetime(6) NULL,
|
||||
`LastSyncAttemptedAt` datetime(6) NULL,
|
||||
`LastSyncSucceededAt` datetime(6) NULL,
|
||||
`LastSyncMode` varchar(255) NULL,
|
||||
`LastSyncSource` varchar(255) NULL,
|
||||
`LastSyncStatus` varchar(255) NULL,
|
||||
`LastSyncError` longtext NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -973,6 +1044,13 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "ImapConnections", "IX_ImapConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -1127,6 +1205,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>();
|
||||
|
||||
@@ -29,5 +29,6 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed class ImapConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = "";
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 993;
|
||||
public bool UseSsl { get; set; } = true;
|
||||
public string Username { get; set; } = "";
|
||||
public string EncryptedPassword { get; set; } = "";
|
||||
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? LastSyncedAt { get; set; }
|
||||
public DateTimeOffset? LastSyncAttemptedAt { get; set; }
|
||||
public DateTimeOffset? LastSyncSucceededAt { get; set; }
|
||||
public string? LastSyncMode { get; set; }
|
||||
public string? LastSyncSource { get; set; }
|
||||
public string? LastSyncStatus { get; set; }
|
||||
public string? LastSyncError { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+4
-4
@@ -59,13 +59,13 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./job-tracker-ui
|
||||
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default
|
||||
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`.
|
||||
# Next's build type-checker needs more than Docker's default 64MB /dev/shm; too little
|
||||
# causes a SIGSEGV during `npm run build`.
|
||||
shm_size: '1gb'
|
||||
args:
|
||||
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
||||
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
||||
# Optional override; default in production is `/api`
|
||||
- REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
||||
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
/.next
|
||||
next-env.d.ts
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# react-scripts (kept only as the Jest test runner, see package.json) still declares a
|
||||
# typescript ^3.2.1||^4 peer constraint that's stale for our actual (Next.js-driven) TS 5.x --
|
||||
# it doesn't type-check via that peer path, so the conflict is safe to relax.
|
||||
legacy-peer-deps=true
|
||||
@@ -2,11 +2,11 @@ FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG REACT_APP_GOOGLE_CLIENT_ID
|
||||
ARG REACT_APP_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ENV REACT_APP_GOOGLE_CLIENT_ID=$REACT_APP_GOOGLE_CLIENT_ID
|
||||
ENV REACT_APP_API_BASE_URL=$REACT_APP_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
@@ -17,7 +17,7 @@ RUN npm run build
|
||||
FROM nginx:1.29.8-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
COPY --from=build /app/out /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
import "../src/index.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jobbjakt",
|
||||
description: "Jobbjakt — track and manage job applications",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||
{ url: "/favicon.ico" },
|
||||
],
|
||||
apple: "/logo192.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#15803d",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// The whole app is a client-side React Router SPA whose providers read window/localStorage
|
||||
// during their initial render -- ssr:false keeps Next's static prerender from ever executing
|
||||
// any of it on the server.
|
||||
const ClientApp = dynamic(() => import("../src/ClientApp"), { ssr: false });
|
||||
|
||||
export default function Page() {
|
||||
return <ClientApp />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// The whole app is client-rendered React Router behind auth (see app/page.tsx) -- static
|
||||
// export keeps the same "one index.html + JS bundle, served by nginx" deploy as CRA had.
|
||||
output: "export",
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+790
-5
@@ -26,11 +26,12 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axios": "^1.15.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript": "^5.9.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
}
|
||||
},
|
||||
@@ -2401,6 +2402,16 @@
|
||||
"postcss-selector-parser": "^6.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
@@ -2659,6 +2670,472 @@
|
||||
"deprecated": "Use @eslint/object-schema instead",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||
@@ -3467,6 +3944,140 @@
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
|
||||
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
|
||||
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
|
||||
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
|
||||
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
|
||||
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
|
||||
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
|
||||
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
|
||||
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
|
||||
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
|
||||
"version": "5.1.1-v1",
|
||||
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
|
||||
@@ -3957,6 +4568,15 @@
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
@@ -6242,6 +6862,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
|
||||
@@ -7215,6 +7841,16 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-newline": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||
@@ -12198,6 +12834,87 @@
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
|
||||
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.10",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
},
|
||||
"bin": {
|
||||
"next": "dist/bin/next"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.10",
|
||||
"@next/swc-darwin-x64": "16.2.10",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.10",
|
||||
"@next/swc-linux-arm64-musl": "16.2.10",
|
||||
"@next/swc-linux-x64-gnu": "16.2.10",
|
||||
"@next/swc-linux-x64-musl": "16.2.10",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.10",
|
||||
"@next/swc-win32-x64-msvc": "16.2.10",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"babel-plugin-react-compiler": "*",
|
||||
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||
"sass": "^1.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@playwright/test": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
@@ -15519,6 +16236,51 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -16076,6 +16838,29 @@
|
||||
"webpack": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"client-only": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/stylehacks": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
|
||||
@@ -16932,16 +17717,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
|
||||
@@ -21,18 +21,19 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axios": "^1.15.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript": "^5.9.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "node --max-old-space-size=4096 ./node_modules/react-scripts/bin/react-scripts.js build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"dev": "next dev",
|
||||
"start": "next dev",
|
||||
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
|
||||
"test": "react-scripts test"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<meta name="theme-color" content="#15803d" />
|
||||
<meta name="description" content="Jobbjakt — track and manage job applications" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Jobbjakt</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+12
-12
@@ -27,11 +27,11 @@ import { PromptProvider } from "./prompt";
|
||||
import JobTable from "./components/JobTable";
|
||||
import type { JobTableColumns } from "./components/JobTable";
|
||||
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import ForgotPasswordPage from "./pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||
import RouteErrorPage from "./pages/RouteErrorPage";
|
||||
import LoginPage from "./views/LoginPage";
|
||||
import LandingPage from "./views/LandingPage";
|
||||
import ForgotPasswordPage from "./views/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "./views/ResetPasswordPage";
|
||||
import RouteErrorPage from "./views/RouteErrorPage";
|
||||
import { api } from "./api";
|
||||
import { resolveCaptureUrl } from "./captureUrl";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
@@ -45,13 +45,13 @@ const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
|
||||
const SettingsView = lazy(() => import("./components/SettingsView"));
|
||||
const RemindersView = lazy(() => import("./components/RemindersView"));
|
||||
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
|
||||
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
|
||||
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
|
||||
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
|
||||
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
|
||||
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage"));
|
||||
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage"));
|
||||
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
|
||||
const ProfilePage = lazy(() => import("./views/ProfilePage"));
|
||||
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
|
||||
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
|
||||
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
|
||||
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
|
||||
const GmailReviewPage = lazy(() => import("./views/GmailReviewPage"));
|
||||
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
|
||||
|
||||
type AuthConfig = { requireAuth: boolean };
|
||||
type MeResponse = {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
||||
|
||||
import App from "./App";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
|
||||
export default function ClientApp() {
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<I18nProvider>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</I18nProvider>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LandingPage from "./views/LandingPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import AdminSystemPage from './pages/AdminSystemPage';
|
||||
import AdminSystemPage from './views/AdminSystemPage';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export function getApiErrorMessage(error: any, fallback = "Request failed.") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const envBaseUrl = process.env.REACT_APP_API_BASE_URL;
|
||||
const envBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
const defaultBaseUrl =
|
||||
window.location.hostname === "localhost"
|
||||
? "http://localhost:5202/api"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
export default function JobbjaktMark(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker" {...props}>
|
||||
<defs>
|
||||
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#14b8a6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a" />
|
||||
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
|
||||
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" />
|
||||
<path d="M14 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
|
||||
<path d="M36 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
|
||||
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0" />
|
||||
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker">
|
||||
<defs>
|
||||
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#14b8a6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a"/>
|
||||
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" stroke-width="4"/>
|
||||
<path d="M14 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M36 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0"/>
|
||||
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1002 B |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
const [working, setWorking] = useState(false);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
|
||||
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
|
||||
const signedIn = Boolean(me?.provider);
|
||||
const actionLabel = !signedIn
|
||||
? t("continueWithGoogle")
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage';
|
||||
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
|
||||
@@ -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",
|
||||
})));
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import GmailReviewPage from './pages/GmailReviewPage';
|
||||
import GmailReviewPage from './views/GmailReviewPage';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
|
||||
@@ -608,7 +608,7 @@ export const translations = {
|
||||
adminSystemCpuMode: "CPU mode",
|
||||
adminSystemNoSmtpHost: "No SMTP host configured",
|
||||
googleAccountTitle: "Google account",
|
||||
googleSetupHint: "Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
|
||||
googleSetupHint: "Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
|
||||
googleLinked: "Linked",
|
||||
googleAvailableToLink: "Available to link",
|
||||
googleLinkedDate: "Linked {date}",
|
||||
@@ -1552,7 +1552,7 @@ export const translations = {
|
||||
adminSystemCpuMode: "CPU-modus",
|
||||
adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert",
|
||||
googleAccountTitle: "Google-konto",
|
||||
googleSetupHint: "Sett `REACT_APP_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
|
||||
googleSetupHint: "Sett `NEXT_PUBLIC_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
|
||||
googleLinked: "Koblet",
|
||||
googleAvailableToLink: "Tilgjengelig for kobling",
|
||||
googleLinkedDate: "Koblet {date}",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<I18nProvider>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</I18nProvider>
|
||||
</LocalizationProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -25,7 +25,7 @@ import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
|
||||
import { ReactComponent as JobbjaktMark } from "../assets/jobbbjakt-mark.svg";
|
||||
import JobbjaktMark from "../assets/JobbjaktMark";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
export type NavItem = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import LoginPage from './views/LoginPage';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import ProfilePage from './views/ProfilePage';
|
||||
import { api } from './api';
|
||||
|
||||
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "es2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
@@ -14,13 +14,26 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
"src",
|
||||
"app",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user