Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29cfeccc87 |
@@ -8,12 +8,6 @@ AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
|
||||
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
|
||||
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
|
||||
GOOGLE_GMAIL_REDIRECT_URI=
|
||||
MICROSOFT_CLIENT_ID=CHANGE_ME_MICROSOFT_CLIENT_ID
|
||||
MICROSOFT_CLIENT_SECRET=CHANGE_ME_MICROSOFT_OAUTH_CLIENT_SECRET
|
||||
# Optional. Defaults to "common" (personal + work/school accounts).
|
||||
MICROSOFT_TENANT_ID=
|
||||
# Optional. If omitted, the backend uses https://<your-domain>/api/microsoft-graph/oauth/callback
|
||||
MICROSOFT_REDIRECT_URI=
|
||||
AI_SERVICE_BASE_URL=http://ai-service:8001
|
||||
# Optional: enables hybrid CV block classification in the local AI service.
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
@@ -13,22 +13,10 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET (resilient)
|
||||
shell: bash
|
||||
# actions/setup-dotnet on this single self-hosted runner intermittently
|
||||
# leaves a partial extraction in the shared tool-cache ("tar: Cannot open:
|
||||
# File exists") or corrupts the SDK download. Install into a clean private
|
||||
# dir via dotnet-install.sh and retry once on failure, mirroring the
|
||||
# npm ci / NuGet retries elsewhere in this workflow.
|
||||
run: |
|
||||
install() {
|
||||
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
|
||||
rm -rf "$HOME/.dotnet"
|
||||
bash /tmp/dotnet-install.sh --channel 9.0 --install-dir "$HOME/.dotnet"
|
||||
}
|
||||
install || ( echo "dotnet install failed ($?) — retrying once..." && install )
|
||||
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
|
||||
"$HOME/.dotnet/dotnet" --info
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -70,12 +58,7 @@ jobs:
|
||||
CI: 'false'
|
||||
GENERATE_SOURCEMAP: 'false'
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# 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 )
|
||||
run: npm run build
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
|
||||
@@ -18,7 +18,6 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
|
||||
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
|
||||
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
|
||||
public DbSet<MicrosoftGraphConnection> MicrosoftGraphConnections => Set<MicrosoftGraphConnection>();
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
||||
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class MicrosoftGraphControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Status_returns_sync_state_fields_for_connected_account()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftGraphConnection
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
MailAddress = "user@outlook.test",
|
||||
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
LastSyncedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
LastSyncAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
LastSyncSucceededAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
LastSyncMode = "list-messages",
|
||||
LastSyncSource = "custom-query",
|
||||
LastSyncStatus = "error",
|
||||
LastSyncError = "Token refresh failed"
|
||||
});
|
||||
|
||||
var controller = CreateController(graph.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<MicrosoftGraphController.MicrosoftGraphConnectionStatusDto>(ok.Value);
|
||||
Assert.True(payload.Connected);
|
||||
Assert.Equal("user@outlook.test", payload.MailAddress);
|
||||
Assert.Equal("list-messages", payload.LastSyncMode);
|
||||
Assert.Equal("custom-query", payload.LastSyncSource);
|
||||
Assert.Equal("error", payload.LastSyncStatus);
|
||||
Assert.Equal("Token refresh failed", payload.LastSyncError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_reports_not_connected_when_no_connection_exists()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((MicrosoftGraphConnection?)null);
|
||||
|
||||
var controller = CreateController(graph.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<MicrosoftGraphController.MicrosoftGraphConnectionStatusDto>(ok.Value);
|
||||
Assert.False(payload.Connected);
|
||||
Assert.Null(payload.MailAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectUrl_returns_authorization_url_from_service()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.BuildAuthorizationUrl("user-1", It.IsAny<string>()))
|
||||
.Returns("https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=test");
|
||||
|
||||
var controller = CreateController(graph.Object, "user-1");
|
||||
var result = controller.ConnectUrl();
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var url = ok.Value!.GetType().GetProperty("url")!.GetValue(ok.Value) as string;
|
||||
Assert.Equal("https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=test", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Callback_returns_error_html_when_state_is_invalid()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.ConsumeState("bad-state")).Returns((string?)null);
|
||||
|
||||
var controller = CreateController(graph.Object, "user-1");
|
||||
var result = await controller.Callback("auth-code", "bad-state", null, CancellationToken.None);
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Contains("no longer valid", content.Content);
|
||||
graph.Verify(service => service.ExchangeCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Callback_returns_error_html_when_provider_returns_error()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>(MockBehavior.Strict);
|
||||
var controller = CreateController(graph.Object, "user-1");
|
||||
|
||||
var result = await controller.Callback(null, null, "access_denied", CancellationToken.None);
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Contains("access_denied", content.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Callback_exchanges_code_and_reports_connected_mail_address()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.ConsumeState("good-state")).Returns("user-1");
|
||||
graph.Setup(service => service.ExchangeCodeAsync("user-1", "auth-code", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftGraphOAuthExchangeResult("user@outlook.test"));
|
||||
|
||||
var controller = CreateController(graph.Object, "user-1");
|
||||
var result = await controller.Callback("auth-code", "good-state", null, CancellationToken.None);
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Contains("user@outlook.test", content.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_calls_service_for_authenticated_user()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var controller = CreateController(graph.Object, "user-1");
|
||||
var result = await controller.Disconnect(CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
graph.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static MicrosoftGraphController CreateController(IMicrosoftGraphOAuthService graph, string userId)
|
||||
{
|
||||
return new MicrosoftGraphController(graph, BuildConfig())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IConfiguration BuildConfig()
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class MicrosoftGraphProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProviderKey_is_microsoft()
|
||||
{
|
||||
var provider = new MicrosoftGraphProvider(Mock.Of<IMicrosoftGraphOAuthService>());
|
||||
Assert.Equal("microsoft", provider.ProviderKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_maps_mail_address_onto_neutral_shape()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new JobTrackerApi.Models.MicrosoftGraphConnection { OwnerUserId = "user-1", MailAddress = "user@outlook.test" });
|
||||
|
||||
var provider = new MicrosoftGraphProvider(graph.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(connection);
|
||||
Assert.Equal("microsoft", connection!.ProviderKey);
|
||||
Assert.Equal("user@outlook.test", connection.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_returns_null_when_not_connected()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((JobTrackerApi.Models.MicrosoftGraphConnection?)null);
|
||||
|
||||
var provider = new MicrosoftGraphProvider(graph.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.Null(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_maps_conversation_id_onto_neutral_thread_id()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<MicrosoftGraphMessageSummary>
|
||||
{
|
||||
new("msg-1", "conv-1", "Interview", "them@company.test", "me@outlook.test", DateTimeOffset.UtcNow, "snippet")
|
||||
});
|
||||
|
||||
var provider = new MicrosoftGraphProvider(graph.Object);
|
||||
var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None);
|
||||
|
||||
var summary = Assert.Single(results);
|
||||
Assert.Equal("msg-1", summary.Id);
|
||||
Assert.Equal("conv-1", summary.ThreadId);
|
||||
Assert.Equal("Interview", summary.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessageAsync_maps_attachment_id_onto_neutral_external_attachment_id()
|
||||
{
|
||||
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
||||
graph.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftGraphMessageDetail(
|
||||
"msg-1", "conv-1", "Offer", "them@company.test", "me@outlook.test", DateTimeOffset.UtcNow, "snippet",
|
||||
"body text", "<p>body</p>", new List<string> { "Inbox" },
|
||||
new List<MicrosoftGraphMessageAttachment> { new("resume.pdf", "application/pdf", 1024, "graph-att-1", false) }));
|
||||
|
||||
var provider = new MicrosoftGraphProvider(graph.Object);
|
||||
var detail = await provider.GetMessageAsync("user-1", "msg-1", CancellationToken.None);
|
||||
|
||||
Assert.Equal("conv-1", detail.ThreadId);
|
||||
var attachment = Assert.Single(detail.Attachments);
|
||||
Assert.Equal("resume.pdf", attachment.FileName);
|
||||
Assert.Equal("graph-att-1", attachment.ExternalAttachmentId);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -19,24 +18,15 @@ public sealed class GmailController : ControllerBase
|
||||
private readonly IGmailJobMatchingService _matching;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IEmailProviderRegistry _providers;
|
||||
|
||||
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null)
|
||||
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
|
||||
{
|
||||
_gmail = gmail;
|
||||
_matching = matching;
|
||||
_db = db;
|
||||
_cfg = cfg;
|
||||
// Fall back to a single-Gmail registry so direct construction (tests) keeps working.
|
||||
_providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) });
|
||||
}
|
||||
|
||||
// The email provider backing this controller's read paths (search + thread listing),
|
||||
// via the provider-neutral seam. OAuth and Gmail-specific candidate ranking still use
|
||||
// IGmailOAuthService directly until they are generalised.
|
||||
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);
|
||||
@@ -393,7 +383,7 @@ public sealed class GmailController : ControllerBase
|
||||
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
|
||||
if (job is null) return NotFound("Job application not found.");
|
||||
|
||||
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||
var distinctMessageIds = threadMessages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
|
||||
.Select(message => message.Id)
|
||||
@@ -649,7 +639,7 @@ public sealed class GmailController : ControllerBase
|
||||
_db.JobApplications.Add(job);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||
// Batch the "already imported?" check with a single query instead of one
|
||||
// AnyAsync per message (N+1), mirroring RelinkThread below.
|
||||
@@ -705,7 +695,7 @@ public sealed class GmailController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||
var existingMessageIds = await _db.Correspondences
|
||||
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||
@@ -803,7 +793,7 @@ public sealed class GmailController : ControllerBase
|
||||
public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var items = await Email.SearchAsync(ownerUserId, query, maxResults, cancellationToken);
|
||||
var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
@@ -907,7 +897,7 @@ public sealed class GmailController : ControllerBase
|
||||
|
||||
foreach (var threadId in linkedThreadIds)
|
||||
{
|
||||
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
var distinctThreadMessages = threadMessages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
|
||||
.GroupBy(message => message.Id, StringComparer.Ordinal)
|
||||
@@ -951,9 +941,9 @@ public sealed class GmailController : ControllerBase
|
||||
|
||||
private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = await Email.GetMessageAsync(ownerUserId, messageId, cancellationToken);
|
||||
var me = await Email.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
var gmailAddress = me?.Address ?? string.Empty;
|
||||
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
|
||||
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
var gmailAddress = me?.GmailAddress ?? string.Empty;
|
||||
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
|
||||
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
|
||||
|
||||
@@ -974,7 +964,7 @@ public sealed class GmailController : ControllerBase
|
||||
FileName = attachment.FileName,
|
||||
MimeType = attachment.MimeType,
|
||||
SizeBytes = attachment.SizeBytes,
|
||||
GmailAttachmentId = attachment.ExternalAttachmentId,
|
||||
GmailAttachmentId = attachment.GmailAttachmentId,
|
||||
Inline = attachment.Inline,
|
||||
})),
|
||||
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Outlook / Microsoft 365 connection lifecycle (connect, OAuth callback, status, disconnect).
|
||||
/// Mirrors the Gmail OAuth surface in <see cref="GmailController"/>. Message search/import runs
|
||||
/// through the provider-neutral <c>IEmailProvider</c> seam once a job's correspondence flow is
|
||||
/// generalised past Gmail; this controller only owns the Microsoft-specific connection lifecycle.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/microsoft-graph")]
|
||||
[Authorize]
|
||||
public sealed class MicrosoftGraphController : ControllerBase
|
||||
{
|
||||
private readonly IMicrosoftGraphOAuthService _graph;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public MicrosoftGraphController(IMicrosoftGraphOAuthService graph, IConfiguration cfg)
|
||||
{
|
||||
_graph = graph;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
public sealed record MicrosoftGraphConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? MailAddress,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<MicrosoftGraphConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return Ok(new MicrosoftGraphConnectionStatusDto(
|
||||
connection is not null,
|
||||
connection?.MailAddress,
|
||||
connection?.ConnectedAt,
|
||||
connection?.LastSyncedAt,
|
||||
connection?.LastSyncAttemptedAt,
|
||||
connection?.LastSyncSucceededAt,
|
||||
connection?.LastSyncMode,
|
||||
connection?.LastSyncSource,
|
||||
connection?.LastSyncStatus,
|
||||
connection?.LastSyncError));
|
||||
}
|
||||
|
||||
[HttpGet("connect-url")]
|
||||
public IActionResult ConnectUrl()
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var url = _graph.BuildAuthorizationUrl(ownerUserId, GetRedirectUri());
|
||||
return Ok(new { url });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oauth/callback")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
return Content(BuildPopupHtml(false, $"Microsoft returned an error: {error}"), "text/html");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
|
||||
{
|
||||
return Content(BuildPopupHtml(false, "Missing Microsoft OAuth code or state."), "text/html");
|
||||
}
|
||||
|
||||
var ownerUserId = _graph.ConsumeState(state);
|
||||
if (string.IsNullOrWhiteSpace(ownerUserId))
|
||||
{
|
||||
return Content(BuildPopupHtml(false, "This Outlook connection request is no longer valid. Start the connection again."), "text/html");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _graph.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken);
|
||||
return Content(BuildPopupHtml(true, $"Connected Outlook: {result.MailAddress}"), "text/html");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Content(BuildPopupHtml(false, ex.Message), "text/html");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("connection")]
|
||||
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
await _graph.DisconnectAsync(ownerUserId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private string GetRequiredOwnerUserId()
|
||||
{
|
||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||
?? throw new InvalidOperationException("Authenticated user id is missing.");
|
||||
}
|
||||
|
||||
private string GetRedirectUri()
|
||||
{
|
||||
var configured = (_cfg["Microsoft:RedirectUri"] ?? "").Trim();
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured;
|
||||
|
||||
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
|
||||
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
|
||||
{
|
||||
return $"{publicBaseUrl}/api/microsoft-graph/oauth/callback";
|
||||
}
|
||||
|
||||
return $"{Request.Scheme}://{Request.Host}/api/microsoft-graph/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 ? "Outlook connected" : "Outlook connection failed";
|
||||
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
|
||||
return $@"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>Outlook 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-microsoft-oauth', status: '{status}', message: {serializedMessage} }}, '*');
|
||||
}}
|
||||
window.close();
|
||||
</script>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
@@ -165,11 +165,9 @@ builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
|
||||
|
||||
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph today; IMAP / manual next).
|
||||
// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / 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.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
namespace JobTrackerApi.Services.EmailProviders
|
||||
{
|
||||
/// <summary>
|
||||
/// Outlook / Microsoft 365 implementation of <see cref="IEmailProvider"/>. Adapts
|
||||
/// <see cref="IMicrosoftGraphOAuthService"/> (Microsoft Graph client) to the provider-neutral
|
||||
/// contract, mapping Graph DTOs to the neutral shapes.
|
||||
/// </summary>
|
||||
public sealed class MicrosoftGraphProvider : IEmailProvider
|
||||
{
|
||||
private readonly IMicrosoftGraphOAuthService _graph;
|
||||
|
||||
public MicrosoftGraphProvider(IMicrosoftGraphOAuthService graph)
|
||||
{
|
||||
_graph = graph;
|
||||
}
|
||||
|
||||
public string ProviderKey => "microsoft";
|
||||
|
||||
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return connection is null ? null : new EmailConnectionInfo("microsoft", connection.MailAddress ?? "");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _graph.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 _graph.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
return messages.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = await _graph.GetMessageAsync(ownerUserId, messageId, cancellationToken);
|
||||
var attachments = detail.Attachments
|
||||
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GraphAttachmentId, a.Inline))
|
||||
.ToList();
|
||||
|
||||
return new EmailMessageDetail(
|
||||
detail.Id,
|
||||
detail.ConversationId,
|
||||
detail.Subject,
|
||||
detail.From,
|
||||
detail.To,
|
||||
detail.Date,
|
||||
detail.Snippet,
|
||||
detail.BodyText,
|
||||
detail.BodyHtml,
|
||||
detail.Labels,
|
||||
attachments);
|
||||
}
|
||||
|
||||
private static EmailMessageSummary ToSummary(MicrosoftGraphMessageSummary m)
|
||||
=> new(m.Id, m.ConversationId, m.Subject, m.From, m.To, m.Date, m.Snippet);
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface IMicrosoftGraphOAuthService
|
||||
{
|
||||
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
|
||||
string? ConsumeState(string state);
|
||||
Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
|
||||
public sealed record MicrosoftGraphMessageSummary(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record MicrosoftGraphMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GraphAttachmentId, bool Inline);
|
||||
public sealed record MicrosoftGraphMessageDetail(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<MicrosoftGraphMessageAttachment> Attachments);
|
||||
|
||||
internal sealed class MicrosoftGraphTokenResponse
|
||||
{
|
||||
public string? access_token { get; set; }
|
||||
public int expires_in { get; set; }
|
||||
public string? refresh_token { get; set; }
|
||||
public string? scope { get; set; }
|
||||
public string? token_type { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outlook / Microsoft 365 mail via Microsoft Graph. Mirrors <see cref="GmailOAuthService"/>'s shape
|
||||
/// (auth-code + offline refresh, encrypted token storage, per-owner connection row) so the two providers
|
||||
/// stay structurally interchangeable behind <see cref="EmailProviders.IEmailProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
{
|
||||
private const string Scope = "openid email profile offline_access https://graph.microsoft.com/Mail.Read";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public MicrosoftGraphOAuthService(
|
||||
IConfiguration cfg,
|
||||
JobTrackerContext db,
|
||||
IDataProtectionProvider protectionProvider,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_db = db;
|
||||
_protector = protectionProvider.CreateProtector("microsoft-graph-oauth-tokens-v1");
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public string BuildAuthorizationUrl(string ownerUserId, string redirectUri)
|
||||
{
|
||||
var clientId = GetRequiredClientId();
|
||||
var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.TrimEnd('=');
|
||||
_cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15));
|
||||
|
||||
var query = new Dictionary<string, string?>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["response_type"] = "code",
|
||||
["response_mode"] = "query",
|
||||
["scope"] = Scope,
|
||||
["state"] = state,
|
||||
};
|
||||
|
||||
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
|
||||
return $"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/authorize?{encoded}";
|
||||
}
|
||||
|
||||
public async Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken);
|
||||
var accessToken = tokens.access_token?.Trim();
|
||||
var refreshToken = tokens.refresh_token?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
throw new InvalidOperationException("Microsoft did not return an access token.");
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
throw new InvalidOperationException("Microsoft did not return a refresh token. Reconnect Outlook and ensure offline_access consent is granted.");
|
||||
|
||||
var mailAddress = await GetProfileAsync(accessToken, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(mailAddress))
|
||||
throw new InvalidOperationException("Microsoft did not return a mail address.");
|
||||
|
||||
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new MicrosoftGraphConnection
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
};
|
||||
_db.MicrosoftGraphConnections.Add(existing);
|
||||
}
|
||||
|
||||
existing.MailAddress = mailAddress.Trim();
|
||||
existing.EncryptedRefreshToken = _protector.Protect(refreshToken);
|
||||
existing.EncryptedAccessToken = _protector.Protect(accessToken);
|
||||
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
|
||||
existing.Scope = tokens.scope?.Trim() ?? Scope;
|
||||
existing.ConnectedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncStatus = "connected";
|
||||
existing.LastSyncSource = "oauth-callback";
|
||||
existing.LastSyncMode = "connect";
|
||||
existing.LastSyncError = null;
|
||||
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new MicrosoftGraphOAuthExchangeResult(existing.MailAddress);
|
||||
}
|
||||
|
||||
public string? ConsumeState(string state)
|
||||
{
|
||||
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cache.Remove(GetStateCacheKey(state));
|
||||
return ownerUserId;
|
||||
}
|
||||
|
||||
public Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null) return;
|
||||
_db.MicrosoftGraphConnections.Remove(existing);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResults = Math.Clamp(maxResults, 1, 25);
|
||||
try
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
|
||||
var url = $"https://graph.microsoft.com/v1.0/me/messages?$top={maxResults}&$select={select}&$orderby=receivedDateTime desc";
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
|
||||
url += $"&$search={Uri.EscapeDataString(EscapeSearchQuery(query.Trim()))}";
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
var results = ReadMessageSummaries(doc.RootElement);
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
return Array.Empty<MicrosoftGraphMessageSummary>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
|
||||
var filter = $"conversationId eq '{conversationId.Trim().Replace("'", "''")}'";
|
||||
var url = $"https://graph.microsoft.com/v1.0/me/messages?$filter={Uri.EscapeDataString(filter)}&$select={select}&$orderby=receivedDateTime asc";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
var results = ReadMessageSummaries(doc.RootElement);
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", true, null, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,body,categories,hasAttachments";
|
||||
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}?$select={select}";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var conversationId = root.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "";
|
||||
var snippet = root.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : "";
|
||||
var categories = root.TryGetProperty("categories", out var categoriesEl) && categoriesEl.ValueKind == JsonValueKind.Array
|
||||
? categoriesEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||
: new List<string>();
|
||||
|
||||
string bodyText = "";
|
||||
string? bodyHtml = null;
|
||||
if (root.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var contentType = bodyEl.TryGetProperty("contentType", out var ctEl) ? ctEl.GetString() ?? "" : "";
|
||||
var content = bodyEl.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : "";
|
||||
if (string.Equals(contentType, "html", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bodyHtml = content;
|
||||
bodyText = StripHtml(content);
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyText = content;
|
||||
}
|
||||
}
|
||||
|
||||
var attachments = root.TryGetProperty("hasAttachments", out var hasAttEl) && hasAttEl.ValueKind == JsonValueKind.True
|
||||
? await ListAttachmentsAsync(client, messageId, cancellationToken)
|
||||
: Array.Empty<MicrosoftGraphMessageAttachment>();
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", true, null, cancellationToken);
|
||||
return new MicrosoftGraphMessageDetail(
|
||||
messageId,
|
||||
conversationId,
|
||||
ReadSubject(root),
|
||||
ReadRecipientAddress(root, "from"),
|
||||
ReadRecipientListAddresses(root, "toRecipients"),
|
||||
ReadReceivedDate(root),
|
||||
snippet,
|
||||
bodyText.Trim(),
|
||||
bodyHtml,
|
||||
categories,
|
||||
attachments);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<MicrosoftGraphMessageAttachment>> ListAttachmentsAsync(HttpClient client, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}/attachments?$select=id,name,contentType,size,isInline";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return Array.Empty<MicrosoftGraphMessageAttachment>();
|
||||
}
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<MicrosoftGraphMessageAttachment>();
|
||||
}
|
||||
|
||||
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageAttachment(
|
||||
item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
|
||||
item.TryGetProperty("contentType", out var typeEl) ? typeEl.GetString() : null,
|
||||
item.TryGetProperty("size", out var sizeEl) && sizeEl.TryGetInt64(out var size) ? size : null,
|
||||
item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null,
|
||||
item.TryGetProperty("isInline", out var inlineEl) && inlineEl.ValueKind == JsonValueKind.True
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
private static List<MicrosoftGraphMessageSummary> ReadMessageSummaries(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new List<MicrosoftGraphMessageSummary>();
|
||||
}
|
||||
|
||||
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageSummary(
|
||||
item.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : "",
|
||||
item.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "",
|
||||
ReadSubject(item),
|
||||
ReadRecipientAddress(item, "from"),
|
||||
ReadRecipientListAddresses(item, "toRecipients"),
|
||||
ReadReceivedDate(item),
|
||||
item.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : ""
|
||||
)).Where(m => !string.IsNullOrWhiteSpace(m.Id)).ToList();
|
||||
}
|
||||
|
||||
private static string ReadSubject(JsonElement item)
|
||||
=> item.TryGetProperty("subject", out var subjectEl) ? subjectEl.GetString() ?? "" : "";
|
||||
|
||||
private static DateTimeOffset? ReadReceivedDate(JsonElement item)
|
||||
=> item.TryGetProperty("receivedDateTime", out var dateEl) && dateEl.ValueKind == JsonValueKind.String
|
||||
&& DateTimeOffset.TryParse(dateEl.GetString(), out var parsed) ? parsed : null;
|
||||
|
||||
private static string ReadRecipientAddress(JsonElement item, string property)
|
||||
{
|
||||
if (!item.TryGetProperty(property, out var recipientEl) || recipientEl.ValueKind != JsonValueKind.Object) return "";
|
||||
if (!recipientEl.TryGetProperty("emailAddress", out var addressEl) || addressEl.ValueKind != JsonValueKind.Object) return "";
|
||||
return addressEl.TryGetProperty("address", out var addrEl) ? addrEl.GetString() ?? "" : "";
|
||||
}
|
||||
|
||||
private static string ReadRecipientListAddresses(JsonElement item, string property)
|
||||
{
|
||||
if (!item.TryGetProperty(property, out var listEl) || listEl.ValueKind != JsonValueKind.Array) return "";
|
||||
var addresses = listEl.EnumerateArray()
|
||||
.Where(r => r.TryGetProperty("emailAddress", out _))
|
||||
.Select(r => r.GetProperty("emailAddress").TryGetProperty("address", out var a) ? a.GetString() : null)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a));
|
||||
return string.Join(", ", addresses);
|
||||
}
|
||||
|
||||
private static string EscapeSearchQuery(string query) => "\"" + query.Replace("\"", "'") + "\"";
|
||||
|
||||
private static string StripHtml(string html)
|
||||
=> System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();
|
||||
|
||||
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null)
|
||||
throw new InvalidOperationException("Outlook is not connected for this account.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
|
||||
connection.AccessTokenExpiresAt is { } expiresAt &&
|
||||
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
||||
{
|
||||
try
|
||||
{
|
||||
return _protector.Unprotect(connection.EncryptedAccessToken);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
connection.EncryptedAccessToken = null;
|
||||
connection.AccessTokenExpiresAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
string refreshToken;
|
||||
try
|
||||
{
|
||||
refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
throw new InvalidOperationException("Your stored Outlook connection can no longer be decrypted after a server key change. Disconnect Outlook and connect it again.");
|
||||
}
|
||||
|
||||
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
|
||||
var accessToken = refreshed.access_token?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
throw new InvalidOperationException("Failed to refresh Outlook access token.");
|
||||
|
||||
connection.EncryptedAccessToken = _protector.Protect(accessToken);
|
||||
connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60));
|
||||
if (!string.IsNullOrWhiteSpace(refreshed.scope))
|
||||
{
|
||||
connection.Scope = refreshed.scope.Trim();
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(refreshed.refresh_token))
|
||||
{
|
||||
connection.EncryptedRefreshToken = _protector.Protect(refreshed.refresh_token.Trim());
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private async Task<MicrosoftGraphTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["code"] = code,
|
||||
["client_id"] = GetRequiredClientId(),
|
||||
["client_secret"] = GetRequiredClientSecret(),
|
||||
["redirect_uri"] = redirectUri,
|
||||
["grant_type"] = "authorization_code",
|
||||
["scope"] = Scope,
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Microsoft token exchange failed: {payload}");
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
|
||||
?? throw new InvalidOperationException("Unable to parse Microsoft token response.");
|
||||
}
|
||||
|
||||
private async Task<MicrosoftGraphTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["refresh_token"] = refreshToken,
|
||||
["client_id"] = GetRequiredClientId(),
|
||||
["client_secret"] = GetRequiredClientSecret(),
|
||||
["grant_type"] = "refresh_token",
|
||||
["scope"] = Scope,
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Microsoft token refresh failed: {payload}");
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
|
||||
?? throw new InvalidOperationException("Unable to parse Microsoft refresh response.");
|
||||
}
|
||||
|
||||
private async Task<string> GetProfileAsync(string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.GetAsync("https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", cancellationToken);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to read Microsoft profile: {payload}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
var mail = doc.RootElement.TryGetProperty("mail", out var mailEl) ? mailEl.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(mail)) return mail;
|
||||
return doc.RootElement.TryGetProperty("userPrincipalName", out var upnEl) ? upnEl.GetString() ?? "" : "";
|
||||
}
|
||||
|
||||
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.MicrosoftGraphConnections.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);
|
||||
}
|
||||
|
||||
private string GetTenant() => (_cfg["Microsoft:TenantId"] ?? "common").Trim();
|
||||
|
||||
private string GetRequiredClientId()
|
||||
{
|
||||
var value = (_cfg["Microsoft:ClientId"] ?? "").Trim();
|
||||
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientId is not configured.");
|
||||
}
|
||||
|
||||
private string GetRequiredClientSecret()
|
||||
{
|
||||
var value = (_cfg["Microsoft:ClientSecret"] ?? "").Trim();
|
||||
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientSecret is not configured.");
|
||||
}
|
||||
|
||||
private static string GetStateCacheKey(string state) => $"microsoft-oauth-state:{state}";
|
||||
}
|
||||
@@ -360,32 +360,6 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
|
||||
}
|
||||
|
||||
static void EnsureMicrosoftGraphConnectionsTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "MicrosoftGraphConnections" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_MicrosoftGraphConnections" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"MailAddress" TEXT NOT NULL,
|
||||
"EncryptedRefreshToken" TEXT NOT NULL,
|
||||
"EncryptedAccessToken" TEXT NULL,
|
||||
"AccessTokenExpiresAt" TEXT NULL,
|
||||
"Scope" 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 INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId" ON "MicrosoftGraphConnections" ("OwnerUserId");""");
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");""");
|
||||
}
|
||||
|
||||
static void EnsureCvTables(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
@@ -454,7 +428,6 @@ public static class StartupInitializationExtensions
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureCvTables(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
@@ -633,7 +606,6 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
||||
@@ -827,30 +799,6 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
|
||||
|
||||
if (!HasMySqlTable(conn, "MicrosoftGraphConnections"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`MailAddress` varchar(512) NOT NULL,
|
||||
`EncryptedRefreshToken` longtext NOT NULL,
|
||||
`EncryptedAccessToken` longtext NULL,
|
||||
`AccessTokenExpiresAt` datetime(6) NULL,
|
||||
`Scope` 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();
|
||||
@@ -942,20 +890,6 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_MicrosoftGraphConnections_OwnerUserId` ON `MicrosoftGraphConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_MicrosoftGraphConnections_OwnerUserId_MailAddress` ON `MicrosoftGraphConnections` (`OwnerUserId`, `MailAddress`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed class MicrosoftGraphConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = "";
|
||||
public string MailAddress { get; set; } = "";
|
||||
public string EncryptedRefreshToken { get; set; } = "";
|
||||
public string? EncryptedAccessToken { get; set; }
|
||||
public DateTimeOffset? AccessTokenExpiresAt { get; set; }
|
||||
public string Scope { 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; }
|
||||
}
|
||||
@@ -23,11 +23,6 @@ services:
|
||||
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
||||
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
|
||||
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
|
||||
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
|
||||
- Microsoft__ClientId=${MICROSOFT_CLIENT_ID}
|
||||
- Microsoft__ClientSecret=${MICROSOFT_CLIENT_SECRET}
|
||||
- Microsoft__TenantId=${MICROSOFT_TENANT_ID}
|
||||
- Microsoft__RedirectUri=${MICROSOFT_REDIRECT_URI}
|
||||
- Ai__BaseUrl=${AI_SERVICE_BASE_URL:-http://ai-service:8001}
|
||||
- Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://ai-service:8001}
|
||||
# Email (SMTP)
|
||||
|
||||
@@ -203,7 +203,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
}, []);
|
||||
|
||||
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
||||
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
|
||||
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
const breadcrumbs = breadcrumbsFor(path, t);
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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 { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function LoginStub() {
|
||||
const location = useLocation() as { state?: { from?: string } };
|
||||
return <div>login page, from={location.state?.from ?? "none"}</div>;
|
||||
}
|
||||
|
||||
// Regression check for the auth-guard fix: a protected route bounces an
|
||||
// unauthenticated visitor to "/" with `state.from` set to the page they
|
||||
// wanted. The home page must forward that state to /login so sign-in
|
||||
// returns them to the originally-requested page instead of dropping them
|
||||
// on /jobs.
|
||||
describe("LandingPage forwards deep-link intent to /login", () => {
|
||||
it("preserves location.state.from through the Sign in CTA", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("401")); // /auth/me: not signed in
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginStub />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/auth/me"));
|
||||
|
||||
const signInButtons = await screen.findAllByText("Sign in", { selector: "button" });
|
||||
await userEvent.click(signInButtons[0]);
|
||||
|
||||
expect(await screen.findByText("login page, from=/jobs/42")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Container, Stack, Typography } from "@mui/material";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
|
||||
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
|
||||
@@ -42,7 +42,6 @@ const PRICING: { name: string; price: string; cadence: string; highlight: boolea
|
||||
|
||||
export default function LandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation() as { state?: { from?: string } };
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// If the visitor already has a session, send them straight into the app.
|
||||
@@ -55,11 +54,6 @@ export default function LandingPage() {
|
||||
return () => { active = false; };
|
||||
}, [navigate]);
|
||||
|
||||
// A protected route redirects unauthenticated visitors here with the page they
|
||||
// wanted in location state; forward it to /login so sign-in returns them there
|
||||
// instead of dropping them on /jobs.
|
||||
const goToLogin = () => navigate("/login", { state: location.state });
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
|
||||
@@ -84,7 +78,7 @@ export default function LandingPage() {
|
||||
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}>✓</Box>
|
||||
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
|
||||
</Stack>
|
||||
<Button variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
||||
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -106,7 +100,7 @@ export default function LandingPage() {
|
||||
right job — all in one focused workspace. Assistive, never autonomous: you approve every draft.
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
||||
Get started
|
||||
</Button>
|
||||
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
|
||||
@@ -223,7 +217,7 @@ export default function LandingPage() {
|
||||
<Button
|
||||
fullWidth
|
||||
variant={tier.highlight ? "contained" : "outlined"}
|
||||
onClick={goToLogin}
|
||||
onClick={() => navigate("/login")}
|
||||
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
|
||||
>
|
||||
{tier.cta}
|
||||
@@ -243,7 +237,7 @@ export default function LandingPage() {
|
||||
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
|
||||
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
|
||||
</Box>
|
||||
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
||||
Sign in →
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -254,7 +248,7 @@ export default function LandingPage() {
|
||||
<Container maxWidth="lg">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack — a focused workspace for the modern job search.</Typography>
|
||||
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user