Add OAth flow for Gmail and update tables and UI
This commit is contained in:
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
||||
AUTH_ADMIN_EMAIL=admin@example.com
|
||||
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
AUTH_GOOGLE_CLIENT_ID=723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com
|
||||
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=
|
||||
|
||||
# Optional: only needed if you want the UI to call a non-default API base URL.
|
||||
# In production the UI defaults to `/api`.
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<Company> Companies => Set<Company>();
|
||||
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
|
||||
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
|
||||
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
||||
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
||||
@@ -58,6 +59,16 @@ namespace JobTrackerApi.Data
|
||||
.HasForeignKey(c => c.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasIndex(x => x.OwnerUserId);
|
||||
|
||||
modelBuilder.Entity<Attachment>()
|
||||
.HasOne(a => a.JobApplication)
|
||||
.WithMany(j => j.Attachments)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -77,6 +77,7 @@ builder.Services.AddControllers();
|
||||
builder.Services.AddDataProtection();
|
||||
builder.Services.AddHostedService<RulesHostedService>();
|
||||
builder.Services.AddHostedService<DailyExportHostedService>();
|
||||
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||
|
||||
builder.Services.AddHttpClient("jobimport")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
@@ -94,6 +95,7 @@ builder.Services.AddHttpClient("summarizer", client =>
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
@@ -449,6 +451,28 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
|
||||
|
||||
EnsureUserRuleSettingsTable(conn);
|
||||
|
||||
static void EnsureGmailConnectionsTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"GmailAddress" TEXT NOT NULL,
|
||||
"EncryptedRefreshToken" TEXT NOT NULL,
|
||||
"EncryptedAccessToken" TEXT NULL,
|
||||
"AccessTokenExpiresAt" TEXT NULL,
|
||||
"Scope" TEXT NOT NULL,
|
||||
"ConnectedAt" TEXT NOT NULL,
|
||||
"LastSyncedAt" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
var isLegacy =
|
||||
@@ -478,6 +502,7 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
|
||||
{
|
||||
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;");
|
||||
}
|
||||
|
||||
// Record the migration as applied.
|
||||
@@ -495,6 +520,7 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
|
||||
// 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", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
||||
|
||||
// Ensure data folder exists before creating/opening SQLite files.
|
||||
Directory.CreateDirectory(paths.DataRoot);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
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 IGmailOAuthService
|
||||
{
|
||||
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
|
||||
string? ConsumeState(string state);
|
||||
Task<GmailOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
|
||||
Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
|
||||
|
||||
internal sealed class GmailTokenResponse
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
public sealed class GmailOAuthService : IGmailOAuthService
|
||||
{
|
||||
private const string Scope = "openid email profile https://www.googleapis.com/auth/gmail.readonly";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public GmailOAuthService(
|
||||
IConfiguration cfg,
|
||||
JobTrackerContext db,
|
||||
IDataProtectionProvider protectionProvider,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_db = db;
|
||||
_protector = protectionProvider.CreateProtector("gmail-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",
|
||||
["access_type"] = "offline",
|
||||
["prompt"] = "consent",
|
||||
["include_granted_scopes"] = "true",
|
||||
["scope"] = Scope,
|
||||
["state"] = state,
|
||||
};
|
||||
|
||||
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
|
||||
return $"https://accounts.google.com/o/oauth2/v2/auth?{encoded}";
|
||||
}
|
||||
|
||||
public async Task<GmailOAuthExchangeResult> 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("Google did not return an access token.");
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
throw new InvalidOperationException("Google did not return a refresh token. Reconnect Gmail and ensure consent is granted.");
|
||||
|
||||
var gmailAddress = await GetProfileAsync(accessToken, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(gmailAddress))
|
||||
throw new InvalidOperationException("Google did not return a Gmail address.");
|
||||
|
||||
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new GmailConnection
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
};
|
||||
_db.GmailConnections.Add(existing);
|
||||
}
|
||||
|
||||
existing.GmailAddress = gmailAddress.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;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new GmailOAuthExchangeResult(existing.GmailAddress);
|
||||
}
|
||||
|
||||
public string? ConsumeState(string state)
|
||||
{
|
||||
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cache.Remove(GetStateCacheKey(state));
|
||||
return ownerUserId;
|
||||
}
|
||||
|
||||
public Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null) return;
|
||||
_db.GmailConnections.Remove(existing);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResults = Math.Clamp(maxResults, 1, 25);
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
url += $"&q={Uri.EscapeDataString(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);
|
||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
var ids = messagesElement.EnumerateArray()
|
||||
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var results = new List<GmailMessageSummary>(ids.Count);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
|
||||
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
|
||||
}
|
||||
|
||||
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
|
||||
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 threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
|
||||
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
|
||||
|
||||
var payload = root.GetProperty("payload");
|
||||
var headers = ReadHeaders(payload);
|
||||
var bodyText = ExtractBody(payload, "text/plain");
|
||||
var bodyHtml = ExtractBody(payload, "text/html");
|
||||
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
|
||||
{
|
||||
bodyText = StripHtml(bodyHtml);
|
||||
}
|
||||
|
||||
return new GmailMessageDetail(
|
||||
messageId,
|
||||
threadId,
|
||||
headers.TryGetValue("subject", out var subject) ? subject : "",
|
||||
headers.TryGetValue("from", out var from) ? from : "",
|
||||
headers.TryGetValue("to", out var to) ? to : "",
|
||||
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
|
||||
snippet,
|
||||
bodyText.Trim(),
|
||||
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null)
|
||||
throw new InvalidOperationException("Gmail is not connected for this account.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
|
||||
connection.AccessTokenExpiresAt is { } expiresAt &&
|
||||
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
||||
{
|
||||
return _protector.Unprotect(connection.EncryptedAccessToken);
|
||||
}
|
||||
|
||||
var refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
|
||||
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
|
||||
var accessToken = refreshed.access_token?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
throw new InvalidOperationException("Failed to refresh Gmail 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();
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private async Task<GmailTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["code"] = code,
|
||||
["client_id"] = GetRequiredClientId(),
|
||||
["client_secret"] = GetRequiredClientSecret(),
|
||||
["redirect_uri"] = redirectUri,
|
||||
["grant_type"] = "authorization_code",
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Google token exchange failed: {payload}");
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
|
||||
?? throw new InvalidOperationException("Unable to parse Google token response.");
|
||||
}
|
||||
|
||||
private async Task<GmailTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["refresh_token"] = refreshToken,
|
||||
["client_id"] = GetRequiredClientId(),
|
||||
["client_secret"] = GetRequiredClientSecret(),
|
||||
["grant_type"] = "refresh_token",
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Google token refresh failed: {payload}");
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
|
||||
?? throw new InvalidOperationException("Unable to parse Google 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://gmail.googleapis.com/gmail/v1/users/me/profile", cancellationToken);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to read Gmail profile: {payload}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
return doc.RootElement.TryGetProperty("emailAddress", out var emailEl)
|
||||
? emailEl.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
|
||||
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null) return;
|
||||
connection.LastSyncedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private string GetRequiredClientId()
|
||||
{
|
||||
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
|
||||
{
|
||||
"" => throw new InvalidOperationException("Google Gmail client ID is not configured."),
|
||||
var value => value,
|
||||
};
|
||||
}
|
||||
|
||||
private string GetRequiredClientSecret()
|
||||
{
|
||||
return (_cfg["Google:GmailClientSecret"] ?? _cfg["Google:ClientSecret"] ?? "").Trim() switch
|
||||
{
|
||||
"" => throw new InvalidOperationException("Google Gmail client secret is not configured."),
|
||||
var value => value,
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStateCacheKey(string state) => $"gmail-oauth-state:{state}";
|
||||
|
||||
private static Dictionary<string, string> ReadHeaders(JsonElement payload)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!payload.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var item in headers.EnumerateArray())
|
||||
{
|
||||
var name = item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
|
||||
var value = item.TryGetProperty("value", out var valueEl) ? valueEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(name) || value is null) continue;
|
||||
result[name.Trim()] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ExtractBody(JsonElement payload, string mimeType)
|
||||
{
|
||||
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
|
||||
string.Equals(mimeTypeEl.GetString(), mimeType, StringComparison.OrdinalIgnoreCase) &&
|
||||
payload.TryGetProperty("body", out var bodyEl) &&
|
||||
bodyEl.TryGetProperty("data", out var dataEl))
|
||||
{
|
||||
return DecodeBody(dataEl.GetString());
|
||||
}
|
||||
|
||||
if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var part in partsEl.EnumerateArray())
|
||||
{
|
||||
var nested = ExtractBody(part, mimeType);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string DecodeBody(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "";
|
||||
var normalized = raw.Replace('-', '+').Replace('_', '/');
|
||||
var padding = normalized.Length % 4;
|
||||
if (padding > 0)
|
||||
{
|
||||
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(normalized);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripHtml(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return "";
|
||||
return System.Text.RegularExpressions.Regex.Replace(html, "<.*?>", string.Empty)
|
||||
.Replace(" ", " ")
|
||||
.Replace("&", "&")
|
||||
.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class JobEnrichmentHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<JobEnrichmentHostedService> _logger;
|
||||
|
||||
public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var summarizer = scope.ServiceProvider.GetRequiredService<ISummarizerService>();
|
||||
|
||||
var jobs = await db.JobApplications
|
||||
.Where(j => !j.IsDeleted)
|
||||
.Where(j => string.IsNullOrWhiteSpace(j.ShortSummary) || string.IsNullOrWhiteSpace(j.Tags))
|
||||
.OrderByDescending(j => j.DateApplied)
|
||||
.Take(20)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
var changed = 0;
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var sourceText = string.IsNullOrWhiteSpace(job.Description) ? job.Notes : job.Description;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.Tags) && !string.IsNullOrWhiteSpace(sourceText))
|
||||
{
|
||||
var tags = SkillTagger.Detect(sourceText)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (tags.Count > 0)
|
||||
{
|
||||
job.Tags = JsonSerializer.Serialize(tags);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.ShortSummary) && !string.IsNullOrWhiteSpace(sourceText))
|
||||
{
|
||||
try
|
||||
{
|
||||
var shortSummary = await summarizer.SummarizeAsync(sourceText, 80, 20);
|
||||
if (!string.IsNullOrWhiteSpace(shortSummary))
|
||||
{
|
||||
job.ShortSummary = shortSummary;
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort; leave for a later pass.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
_logger.LogInformation("Backfilled tags/summaries for {Count} job fields.", changed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Job enrichment background pass failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace JobTrackerApi.Models
|
||||
public string From { get; set; } = ""; // "Me" or "Company"
|
||||
public string? Subject { get; set; }
|
||||
public string? Channel { get; set; } // e.g. Email, Call, Note
|
||||
public string? ExternalMessageId { get; set; }
|
||||
public string Content { get; set; } = "";
|
||||
public DateTime Date { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed class GmailConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = "";
|
||||
public string GmailAddress { 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; }
|
||||
}
|
||||
@@ -19,6 +19,8 @@ services:
|
||||
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
|
||||
# Optional: allow Google ID-token bearer auth
|
||||
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
||||
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
|
||||
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
|
||||
# Email (SMTP)
|
||||
- App__PublicBaseUrl=${APP_PUBLIC_BASE_URL}
|
||||
- Email__Enabled=${EMAIL_ENABLED}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -25,6 +26,8 @@ import { useToast } from "../toast";
|
||||
|
||||
export default function CompaniesTable() {
|
||||
const { toast } = useToast();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Company | null>(null);
|
||||
@@ -40,6 +43,17 @@ export default function CompaniesTable() {
|
||||
api.get<Company[]>("/companies").then((r) => setCompanies(r.data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const editId = Number(params.get("edit") || 0);
|
||||
if (!editId || companies.length === 0) return;
|
||||
const company = companies.find((c) => c.id === editId);
|
||||
if (!company) return;
|
||||
openEdit(company);
|
||||
params.delete("edit");
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "" }, { replace: true });
|
||||
}, [companies, location.pathname, location.search, navigate]);
|
||||
|
||||
const openEdit = (c: Company) => {
|
||||
setEditing(c);
|
||||
setRecruiterName(c.recruiterName ?? "");
|
||||
|
||||
@@ -3,11 +3,20 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
@@ -16,7 +25,7 @@ import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
import { api } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { CorrespondenceMessage } from "../types";
|
||||
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
||||
|
||||
function parseRawEmail(raw: string): {
|
||||
subject?: string;
|
||||
@@ -68,13 +77,50 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importTab, setImportTab] = useState(0);
|
||||
const [rawEmail, setRawEmail] = useState("");
|
||||
|
||||
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
||||
const [gmailLoading, setGmailLoading] = useState(false);
|
||||
const [gmailQuery, setGmailQuery] = useState("");
|
||||
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
|
||||
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
|
||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
|
||||
setMessages(res.data);
|
||||
}, [jobId]);
|
||||
|
||||
const loadGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
setGmailLoading(true);
|
||||
const res = await api.get<GmailStatus>("/gmail/status");
|
||||
setGmailStatus(res.data);
|
||||
} catch {
|
||||
setGmailStatus({ connected: false });
|
||||
} finally {
|
||||
setGmailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadGmailMessages = useCallback(async () => {
|
||||
try {
|
||||
setGmailMessagesLoading(true);
|
||||
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
|
||||
params: {
|
||||
query: gmailQuery.trim() || undefined,
|
||||
maxResults: 12,
|
||||
},
|
||||
});
|
||||
setGmailMessages(res.data);
|
||||
} catch {
|
||||
toast("Failed to load Gmail messages.", "error");
|
||||
} finally {
|
||||
setGmailMessagesLoading(false);
|
||||
}
|
||||
}, [gmailQuery, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -85,6 +131,34 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen) return;
|
||||
void loadGmailStatus();
|
||||
}, [importOpen, loadGmailStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
||||
void loadGmailMessages();
|
||||
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data as { source?: string; status?: string; message?: string };
|
||||
if (data?.source !== "jobtracker-gmail-oauth") return;
|
||||
if (data.status === "connected") {
|
||||
toast(data.message || "Gmail connected.", "success");
|
||||
void loadGmailStatus();
|
||||
setImportTab(1);
|
||||
void loadGmailMessages();
|
||||
} else {
|
||||
toast(data.message || "Gmail connection failed.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [loadGmailMessages, loadGmailStatus, toast]);
|
||||
|
||||
const canSend = useMemo(() => text.trim().length > 0, [text]);
|
||||
|
||||
const send = async () => {
|
||||
@@ -130,6 +204,45 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
}
|
||||
};
|
||||
|
||||
const connectGmail = async () => {
|
||||
try {
|
||||
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
||||
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) {
|
||||
toast("Your browser blocked the Gmail popup.", "error");
|
||||
}
|
||||
} catch {
|
||||
toast("Failed to start Gmail connection.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectGmail = async () => {
|
||||
try {
|
||||
await api.delete("/gmail/connection");
|
||||
setGmailStatus({ connected: false });
|
||||
setGmailMessages([]);
|
||||
toast("Gmail disconnected.", "success");
|
||||
} catch {
|
||||
toast("Failed to disconnect Gmail.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const importGmailMessage = async (messageId: string) => {
|
||||
try {
|
||||
setImportingMessageId(messageId);
|
||||
await api.post("/gmail/import", {
|
||||
jobApplicationId: jobId,
|
||||
messageId,
|
||||
});
|
||||
await load();
|
||||
toast("Email imported from Gmail.", "success");
|
||||
} catch {
|
||||
toast("Failed to import Gmail message.", "error");
|
||||
} finally {
|
||||
setImportingMessageId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Paper
|
||||
@@ -190,8 +303,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
sx={{ display: "block", mt: 0.75, color: "text.secondary" }}
|
||||
>
|
||||
{isMe ? "Me" : "Company"}
|
||||
{m.channel ? ` · ${m.channel}` : ""}
|
||||
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
|
||||
{m.channel ? ` · ${m.channel}` : ""}
|
||||
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -249,38 +362,143 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>Import Email</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
||||
Paste raw email text (headers optional). We parse Subject and Date when present.
|
||||
</Typography>
|
||||
<textarea
|
||||
value={rawEmail}
|
||||
onChange={(e) => setRawEmail(e.target.value)}
|
||||
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 220,
|
||||
resize: "vertical",
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
border:
|
||||
theme.palette.mode === "dark"
|
||||
? "1px solid rgba(148,163,184,0.25)"
|
||||
: "1px solid rgba(15,23,42,0.12)",
|
||||
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: "inherit",
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Paste email" />
|
||||
<Tab label="Gmail" />
|
||||
</Tabs>
|
||||
|
||||
{importTab === 0 ? (
|
||||
<>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
||||
Paste raw email text (headers optional). We parse Subject and Date when present.
|
||||
</Typography>
|
||||
<textarea
|
||||
value={rawEmail}
|
||||
onChange={(e) => setRawEmail(e.target.value)}
|
||||
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 220,
|
||||
resize: "vertical",
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
border:
|
||||
theme.palette.mode === "dark"
|
||||
? "1px solid rgba(148,163,184,0.25)"
|
||||
: "1px solid rgba(15,23,42,0.12)",
|
||||
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: "inherit",
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800 }}>Google Gmail</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{gmailLoading ? "Checking connection..." : gmailStatus?.connected ? `Connected as ${gmailStatus.gmailAddress}` : "Connect your Gmail account to browse recent emails."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{gmailStatus?.connected ? (
|
||||
<>
|
||||
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="contained" onClick={() => void connectGmail()}>
|
||||
Connect Gmail
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{gmailStatus?.connected ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<TextField
|
||||
label="Search Gmail"
|
||||
value={gmailQuery}
|
||||
onChange={(e) => setGmailQuery(e.target.value)}
|
||||
placeholder="from:company@example.com OR interview"
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
|
||||
Search
|
||||
</Button>
|
||||
</Box>
|
||||
{gmailStatus.lastSyncedAt ? (
|
||||
<Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" />
|
||||
) : null}
|
||||
<Paper variant="outlined" sx={{ maxHeight: 360, overflowY: "auto" }}>
|
||||
{gmailMessagesLoading ? (
|
||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : gmailMessages.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", p: 2 }}>No Gmail messages found.</Typography>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{gmailMessages.map((message, index) => (
|
||||
<React.Fragment key={message.id}>
|
||||
{index > 0 ? <Divider /> : null}
|
||||
<ListItemButton sx={{ alignItems: "flex-start", py: 1.5 }}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{message.subject || "(No subject)"}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{message.date ? new Date(message.date).toLocaleString() : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.primary" }}>
|
||||
From: {message.from || "Unknown"}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>
|
||||
{message.snippet}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={importingMessageId === message.id}
|
||||
onClick={() => void importGmailMessage(message.id)}
|
||||
>
|
||||
{importingMessageId === message.id ? "Importing..." : "Import"}
|
||||
</Button>
|
||||
</ListItemButton>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setImportOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={importEmail}>
|
||||
Log Email
|
||||
</Button>
|
||||
<Button onClick={() => setImportOpen(false)}>Close</Button>
|
||||
{importTab === 0 ? (
|
||||
<Button variant="contained" onClick={importEmail}>
|
||||
Log Email
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
@@ -55,12 +56,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
|
||||
const [loadingDraft, setLoadingDraft] = useState(false);
|
||||
const [sendingDraft, setSendingDraft] = useState(false);
|
||||
const [draftRecipient, setDraftRecipient] = useState("");
|
||||
const [draftSubject, setDraftSubject] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
setTab(0);
|
||||
setFollowUpDraft(null);
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => setJob(r.data));
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); setDraftRecipient(r.data.company?.recruiterEmail ?? ""); });
|
||||
api
|
||||
.get(`/auth/me`)
|
||||
.then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))))
|
||||
@@ -76,7 +81,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
setLoadingDraft(true);
|
||||
api
|
||||
.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`)
|
||||
.then((r) => setFollowUpDraft(r.data))
|
||||
.then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); })
|
||||
.catch(() => setFollowUpDraft(null))
|
||||
.finally(() => setLoadingDraft(false));
|
||||
}, [open, jobId, tab, followUpDraft]);
|
||||
@@ -216,17 +221,31 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
<Typography variant="overline">Suggested send date</Typography>
|
||||
<Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Subject</Typography>
|
||||
<Typography sx={{ fontWeight: 700 }}>{followUpDraft.subject}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Draft</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{followUpDraft.body}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button variant="contained" onClick={() => navigator.clipboard.writeText(`${followUpDraft.subject}\n\n${followUpDraft.body}`)}>
|
||||
Copy draft
|
||||
<TextField label="Recipient" value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText="Defaults to the company recruiter email when available." />
|
||||
<TextField label="Subject" value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
|
||||
<TextField label="Draft" multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} />
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>Copy draft</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()}
|
||||
onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setSendingDraft(true);
|
||||
try {
|
||||
await api.post(`/jobapplications/${jobId}/send-followup`, {
|
||||
toEmail: draftRecipient || null,
|
||||
subject: draftSubject,
|
||||
body: draftBody,
|
||||
nextFollowUpAt: followUpDraft.suggestedSendOn || null,
|
||||
});
|
||||
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
|
||||
} finally {
|
||||
setSendingDraft(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sendingDraft ? "Sending..." : "Send and log email"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -122,6 +123,8 @@ function statusTone(status: string): string {
|
||||
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
|
||||
const theme = useTheme();
|
||||
const { toast } = useToast();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -167,6 +170,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
});
|
||||
}, [params, refreshToken, reloadToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const paramsSearch = new URLSearchParams(location.search);
|
||||
const openId = Number(paramsSearch.get("open") || 0);
|
||||
if (!openId || jobs.length === 0) return;
|
||||
const job = jobs.find((j) => j.id === openId);
|
||||
if (!job) return;
|
||||
setDetailsJobId(openId);
|
||||
paramsSearch.delete("open");
|
||||
navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true });
|
||||
}, [jobs, location.pathname, location.search, navigate]);
|
||||
|
||||
const requestSort = (key: typeof sortBy) => {
|
||||
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else {
|
||||
|
||||
@@ -101,13 +101,13 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
id: `job-${job.id}`,
|
||||
label: `${job.company?.name ?? "Company"} - ${job.jobTitle}`,
|
||||
hint: "Open job list and search result",
|
||||
action: () => onNavigate(`/jobs`),
|
||||
action: () => onNavigate(`/jobs?open=${job.id}`),
|
||||
})),
|
||||
...companies.slice(0, 6).map((company) => ({
|
||||
id: `company-${company.id}`,
|
||||
label: company.name,
|
||||
hint: "Open companies",
|
||||
action: () => onNavigate(`/companies`),
|
||||
action: () => onNavigate(`/companies?edit=${company.id}`),
|
||||
})),
|
||||
];
|
||||
|
||||
|
||||
@@ -58,6 +58,24 @@ export interface CorrespondenceMessage {
|
||||
date: string;
|
||||
}
|
||||
|
||||
|
||||
export interface GmailStatus {
|
||||
connected: boolean;
|
||||
gmailAddress?: string;
|
||||
connectedAt?: string;
|
||||
lastSyncedAt?: string;
|
||||
}
|
||||
|
||||
export interface GmailMessageSummary {
|
||||
id: string;
|
||||
threadId: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date?: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface JobImportResult {
|
||||
title?: string;
|
||||
company?: string;
|
||||
|
||||
Reference in New Issue
Block a user