Add OAth flow for Gmail and update tables and UI
This commit is contained in:
@@ -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>";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user