Add OAth flow for Gmail and update tables and UI

This commit is contained in:
cesnimda
2026-03-21 14:02:19 +01:00
parent 51a539068f
commit ed68e44eaf
17 changed files with 1180 additions and 53 deletions
@@ -20,6 +20,29 @@ namespace JobTrackerApi.Controllers
private string? CurrentUserId =>
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
private static string? NormalizeSource(string? source)
{
if (string.IsNullOrWhiteSpace(source)) return null;
var value = source.Trim();
if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrWhiteSpace(uri.Host))
{
value = uri.Host;
}
value = value.Replace("www.", "", StringComparison.OrdinalIgnoreCase).Trim().Trim('/');
var lower = value.ToLowerInvariant();
return lower switch
{
"linkedin" or "linkedin.com" => "LinkedIn",
"finn" or "finn.no" => "Finn",
"nav" or "nav.no" => "NAV",
"jobbnorge" or "jobbnorge.no" => "Jobbnorge",
_ => string.Join(" ", value.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()))
};
}
[HttpGet]
public async Task<ActionResult<List<Company>>> GetAll(CancellationToken cancellationToken)
{
@@ -86,7 +109,7 @@ namespace JobTrackerApi.Controllers
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
Name = name,
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim(),
Source = NormalizeSource(request.Source),
};
_db.Companies.Add(company);
@@ -111,7 +134,7 @@ namespace JobTrackerApi.Controllers
company.Name = name;
company.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
company.Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim();
company.Source = NormalizeSource(request.Source);
company.RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim();
company.RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim();
@@ -0,0 +1,187 @@
using System.Security.Claims;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/gmail")]
[Authorize]
public sealed class GmailController : ControllerBase
{
private readonly IGmailOAuthService _gmail;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
public GmailController(IGmailOAuthService gmail, JobTrackerContext db, IConfiguration cfg)
{
_gmail = gmail;
_db = db;
_cfg = cfg;
}
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
return Ok(new
{
connected = connection is not null,
gmailAddress = connection?.GmailAddress,
connectedAt = connection?.ConnectedAt,
lastSyncedAt = connection?.LastSyncedAt,
});
}
[HttpGet("connect-url")]
public IActionResult ConnectUrl()
{
var ownerUserId = GetRequiredOwnerUserId();
var url = _gmail.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, $"Google returned an error: {error}"), "text/html");
}
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
{
return Content(BuildPopupHtml(false, "Missing Google OAuth code or state."), "text/html");
}
var ownerUserId = _gmail.ConsumeState(state);
if (string.IsNullOrWhiteSpace(ownerUserId))
{
return Content(BuildPopupHtml(false, "This Gmail connection request is no longer valid. Start the connection again."), "text/html");
}
try
{
var result = await _gmail.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken);
return Content(BuildPopupHtml(true, $"Connected Gmail: {result.GmailAddress}"), "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 _gmail.DisconnectAsync(ownerUserId, cancellationToken);
return NoContent();
}
[HttpGet("messages")]
public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
{
var ownerUserId = GetRequiredOwnerUserId();
var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return Ok(items);
}
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
[HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required.");
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var existing = await _db.Correspondences.FirstOrDefaultAsync(
x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId,
cancellationToken);
if (existing is not null)
{
return Ok(existing);
}
var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var message = new Correspondence
{
JobApplicationId = request.JobApplicationId,
From = isMe ? "Me" : "Company",
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
Channel = "Email",
ExternalMessageId = detail.Id,
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = detail.Date?.LocalDateTime ?? DateTime.Now,
};
_db.Correspondences.Add(message);
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(cancellationToken);
return Ok(message);
}
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["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
{
return $"{publicBaseUrl}/api/gmail/oauth/callback";
}
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>";
}
}
@@ -14,11 +14,13 @@ namespace JobTrackerApi.Controllers
{
private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer;
private readonly IAppEmailSender _email;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email)
{
_db = db;
_summarizer = summarizer;
_email = email;
}
private string? CurrentUserId =>
@@ -68,6 +70,30 @@ namespace JobTrackerApi.Controllers
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
}
private static string? NormalizeTags(string? raw)
{
var normalized = SplitTags(raw)
.Select(tag => tag.Trim())
.Where(tag => tag.Length > 0)
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()));
})
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
}
private static string? NormalizeUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var value = url.Trim();
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
}
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
@@ -538,10 +564,10 @@ namespace JobTrackerApi.Controllers
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
DescriptionLanguage = string.IsNullOrWhiteSpace(request.DescriptionLanguage) ? null : request.DescriptionLanguage.Trim(),
Tags = string.IsNullOrWhiteSpace(request.Tags) ? null : request.Tags,
Tags = NormalizeTags(request.Tags),
Deadline = request.Deadline,
CoverLetterText = string.IsNullOrWhiteSpace(request.CoverLetterText) ? null : request.CoverLetterText,
JobUrl = string.IsNullOrWhiteSpace(request.JobUrl) ? null : request.JobUrl,
JobUrl = NormalizeUrl(request.JobUrl),
DateApplied = request.DateApplied ?? DateTime.Now,
ResponseReceived = false,
ResponseDate = null,
@@ -635,10 +661,10 @@ namespace JobTrackerApi.Controllers
job.Description = request.Description;
job.TranslatedDescription = request.TranslatedDescription;
job.DescriptionLanguage = request.DescriptionLanguage;
job.Tags = request.Tags;
job.Tags = NormalizeTags(request.Tags);
job.Deadline = request.Deadline;
job.CoverLetterText = request.CoverLetterText;
job.JobUrl = request.JobUrl;
job.JobUrl = NormalizeUrl(request.JobUrl);
if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value;
if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate)
@@ -1076,6 +1102,7 @@ namespace JobTrackerApi.Controllers
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
[HttpGet("analytics-overview")]
@@ -1310,6 +1337,50 @@ namespace JobTrackerApi.Controllers
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
}
[HttpPost("{id:int}/send-followup")]
public async Task<IActionResult> SendFollowUp([FromRoute] int id, [FromBody] SendFollowUpRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (string.IsNullOrWhiteSpace(request.Subject)) return BadRequest("Subject is required.");
if (string.IsNullOrWhiteSpace(request.Body)) return BadRequest("Body is required.");
var toEmail = (request.ToEmail ?? job.Company?.RecruiterEmail ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required.");
await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken);
_db.Correspondences.Add(new Correspondence
{
JobApplicationId = id,
From = "Me",
Subject = request.Subject.Trim(),
Channel = "Email",
Content = request.Body.Trim(),
Date = DateTime.Now,
});
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.Now;
if (request.NextFollowUpAt is not null)
{
job.Company.NextContactAt = request.NextFollowUpAt.Value;
}
}
if (request.NextFollowUpAt is not null)
{
job.FollowUpAt = request.NextFollowUpAt.Value;
}
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("summarizer-metrics")]
public async Task<ActionResult<SummarizerMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
{