Compare commits

..

12 Commits

Author SHA1 Message Date
cesnimda ab79072e52 refactor(gmail): extract DTOs and static helpers from GmailController
CI and Deploy / test (pull_request) Successful in 2m3s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 3 (Wave 2), GmailController slice. Pure mechanical extraction,
no behaviour change:

- GmailDtos.cs: the 26 inline record DTOs, moved to a partial-class file so
  every existing GmailController.XyzDto reference (tests included) keeps
  working unchanged.
- GmailParsing.cs: the 8 pure static helpers (ApplySyncBoundary,
  LooksLikeJobRelatedThread, ToConfidence, ExtractFirstEmail/RecruiterName/
  CompanyName/RoleFromSubject, BuildPopupHtml), same partial-class approach.

GmailController.cs: 1200 -> 1022 lines. 169/169 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:45:48 +02:00
cesnimda 6a43227315 perf(gmail): narrow review-decision lookup to the single ThreadId
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 2. CreateSuggestedJob, RelinkThread, and UnlinkThread each
upserted exactly one GmailReviewDecision by ThreadId but loaded every review
decision for the owner (GmailReviewDecisions.Where(OwnerUserId == x).ToList())
just to linear-scan for the one match. Replaced with FirstOrDefaultAsync
filtered on both OwnerUserId and ThreadId, and added a single-row
UpsertReviewDecision overload alongside the existing dictionary-based one
(still used by the review-queue endpoints, which genuinely need every
decision at once to render the queue).

169/169 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:38:26 +02:00
cesnimda 9b21d5c65d Merge pull request 'chore(db): resync stale EF ModelSnapshot + fix fresh-DB schema gap' (#16) from chore/resync-ef-snapshot into main
CI and Deploy / test (push) Successful in 2m6s
CI and Deploy / deploy (push) Successful in 47s
2026-07-11 20:37:01 +02:00
cesnimda a9a0ddecbc chore(db): resync stale EF ModelSnapshot + fix fresh-DB schema gap
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 1. The committed ModelSnapshot was empty/stale (21 lines, no
entities) -- `dotnet ef migrations add` scaffolded the whole database from
scratch against it, including the ASP.NET Identity tables, which have never
been created by a real EF migration in this repo (always provisioned via the
raw-SQL reconciler in StartupInitializationExtensions.cs -- see
EnsureIdentityTables' own comment). Applying that diff for real would throw
"table/column already exists" on every environment.

Fix: added migration 20260711181039_SyncModelSnapshot with an intentionally
empty Up()/Down() (see its doc comment) -- it only records itself in
__EFMigrationsHistory and regenerates the snapshot to match the live model,
so `dotnet ef migrations add` produces a real diff for the next schema
change instead of the whole database again. Verified zero side effects
against a copy of the dev DB (only inserts one history row) and against a
fresh empty DB (full migration + reconciler chain runs clean).

That fresh-DB verification surfaced a real, previously-undiscovered bug:
EnsureColumn/EnsureMySqlColumn calls for JobApplications/Correspondences/
Companies/Attachments ad-hoc columns all no-op on a truly fresh database
(the tables don't exist yet -- Migrate() creates them afterward), so a
brand-new deployment's first boot would be missing dozens of columns
(LastReminderEmailSentAt, RecruiterMessageDraft, salary fields, Correspondence
Provider/Subject/Channel/etc.) until the next restart. Also caught: my own b4
change (Correspondence.Provider backfill, already merged) had the same
unguarded-on-fresh-DB bug in isolation.

Fixed by promoting the schema-reconciliation helpers (Exec/HasTable/
HasColumn/EnsureColumn and their MySQL equivalents) from local functions to
class-level statics, extracting the ad-hoc-column blocks into
ReconcileCoreAppColumns/ReconcileCoreAppColumnsMySql, and calling them a
second time right after Migrate() succeeds (reusing the connection already
opened for the CoreSchemaReady check) -- idempotent, so free on every boot
except the first one, where it's now required. No inline logic changed,
pure extraction + one additional call site.

Also added Microsoft.EntityFrameworkCore.Design to JobTrackerApi.csproj
(dotnet-ef tooling requires it on the startup project since EF Core 6+;
previously only referenced by JobTrackerBackend, where the DbContext lives).

169/169 backend tests green. Verified live: full app boot against both a
fresh empty SQLite DB and a copy of the populated dev DB, both clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:32:07 +02:00
cesnimda 408da93fc7 Merge pull request 'feat(email): add provider picker to Settings > Account' (#14) from feat/email-provider-picker-ui into main
CI and Deploy / test (push) Successful in 2m3s
CI and Deploy / deploy (push) Successful in 55s
2026-07-11 19:59:25 +02:00
cesnimda 6db3bffb2f feat(email): add provider picker to Settings > Account
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
b5 of the multi-provider email roadmap (frontend). Adds EmailProviderConnections
-- one card per provider (Gmail, Outlook/Microsoft 365, generic IMAP) showing
connect status and connect/disconnect actions, mounted in SettingsView's
Account tab alongside the existing app-login GoogleAuthCard (a separate
concern: that card is sign-in identity, this is mailbox linking).

Gmail and Microsoft reuse the OAuth-popup + postMessage handshake already
built server-side (mirrors Correspondence.tsx's existing Gmail-connect flow).
IMAP has no OAuth step, so it's a plain host/port/ssl/username/password form
posting to /api/imap/connect, which verifies the connection server-side
before storing it.

Deliberately NOT touched: the Gmail-specific job-candidate-matching/review UI
in Correspondence.tsx and GmailReviewPage.tsx. That backend pipeline
(ListJobCandidateMessagesAsync, GmailReviewDecisions) is still Gmail-only by
design -- generalising it now would mean building fake UI for capabilities
Microsoft/IMAP don't have yet. This is scoped to the piece that's actually
provider-neutral: connect/disconnect status.

Verified live (backend + frontend dev servers): logged in, confirmed all
three /status calls return 200, Gmail connect-url fetch succeeds, IMAP form
submit hits /api/imap/connect and surfaces the expected 400 on a bad host.

Frontend suite: 25 suites / 57 tests green (2 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:53:51 +02:00
cesnimda c0d620f528 Merge pull request 'feat(email): add Correspondence.Provider discriminator' (#13) from feat/correspondence-provider-discriminator into main
CI and Deploy / test (push) Successful in 2m7s
CI and Deploy / deploy (push) Successful in 43s
2026-07-11 19:53:32 +02:00
cesnimda cb2715c323 feat(email): add Correspondence.Provider discriminator
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
b4 of the multi-provider email roadmap. The manual/free-text correspondence
entry path already existed (CorrespondenceController.Create) -- this slice
was narrower than the roadmap wording suggests: tag every Correspondence row
with which provider it came from (gmail | manual today; microsoft | imap
once those providers grow an import-into-Correspondence path of their own),
not build a new endpoint.

- Correspondence.Provider (nullable string), reconciled via the existing
  EnsureColumn pattern (SQLite + MySQL).
- Idempotent backfill: rows with an ExternalThreadId (historically only
  ever written by Gmail import) get 'gmail'; everything else gets 'manual'.
- GmailController.ImportSingleMessageAsync now tags Provider = "gmail".
- CorrespondenceController.Create now tags Provider = "manual".
- Both write sites use a fixed literal, not request input -- no injection
  surface introduced. Backfill SQL is static, no interpolation.

148/148 green (147 existing + 1 new CorrespondenceControllerTests; the
GmailController import test gained a Provider assertion in place).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:47:52 +02:00
cesnimda d308f1d5d4 Merge pull request 'feat(email): add ImapProvider (generic IMAP for unsupported providers)' (#12) from feat/imap-provider into main
CI and Deploy / test (push) Successful in 2m8s
CI and Deploy / deploy (push) Successful in 48s
2026-07-11 19:47:31 +02:00
cesnimda a8e2f4dc4a feat(email): add ImapProvider (generic IMAP for unsupported providers)
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
b3 of the multi-provider email roadmap. Adds ImapConnection model + table
(reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client),
ImapProvider implementing the existing IEmailProvider contract unchanged,
and ImapController for credential-based connect (no OAuth — user supplies
host/username/password directly, verified by a live connect before storage).

Scope, documented inline with ponytail: comments:
- INBOX only, no multi-folder support.
- Thread grouping approximates the References/In-Reply-To chain root rather
  than the IMAP THREAD extension, which not every server implements.
- External message ids are IMAP UIDs, scoped to the connection's current
  UIDVALIDITY.

Security: ran the security-audit skill against this diff (credential
handling + arbitrary-host connect is exactly the class of change the
standing security gate exists for). Found and fixed a real SSRF: the
connect endpoint let an authenticated user point the server at an
arbitrary host:port with no internal-range check, and connect-vs-auth
failure was distinguishable to the caller -- together a working oracle to
fingerprint internal services (loopback/RFC1918/link-local/cloud metadata)
from the server's network position. Fixed with EnsureHostIsExternalAsync
(DNS-resolve + reject internal ranges, re-checked on every reconnect to
close the DNS-rebinding gap) and a single generic failure message that no
longer distinguishes connect vs auth failure. 7 regression tests added.

Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj --
stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing)
would be a large, security-sensitive protocol implementation nobody asked
for, so this is the correct dependency, not a stdlib substitute.

168/168 green (161 existing + 7 new SSRF regression tests; the earlier
14 IMAP feature tests are included in the 161).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:40:50 +02:00
cesnimda 8edbdceee9 Merge pull request 'ci: retry frontend build once on silent failure' (#15) from ci/retry-frontend-build into main
CI and Deploy / test (push) Successful in 2m2s
CI and Deploy / deploy (push) Successful in 22s
2026-07-11 19:40:24 +02:00
cesnimda 4f98195592 ci: retry frontend build once on silent failure
CI and Deploy / test (pull_request) Successful in 1m58s
CI and Deploy / deploy (pull_request) Has been skipped
npm run build (Terser minify + fork-ts-checker workers) has now died three
distinct ways on this runner in this session: a printed Terser minify error,
an explicit SIGSEGV, and a fully silent kill with zero output between
'Creating an optimized production build...' and the failure line (OOM/SIGSEGV
signature — process killed before it could flush an error). All three are the
same resource-starved-runner class as the npm ci and dotnet-install flakes
already retried elsewhere in this workflow. Retry once, matching that pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:30:15 +02:00
17 changed files with 3163 additions and 340 deletions
+6 -1
View File
@@ -70,7 +70,12 @@ jobs:
CI: 'false'
GENERATE_SOURCEMAP: 'false'
NODE_OPTIONS: --max-old-space-size=4096
run: npm run build
# CRA's build (Terser minify + fork-ts-checker workers) has repeatedly died silently on
# this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner
# class as the npm ci and dotnet-install flakes elsewhere in this workflow). Retry once.
run: |
npm run build \
|| ( echo "Frontend build failed ($?) — retrying once..." && npm run build )
deploy:
needs: test
@@ -0,0 +1,35 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CorrespondenceControllerTests
{
[Fact]
public async Task Create_tags_manually_entered_correspondence_with_manual_provider()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = new CorrespondenceController(db);
var request = new CorrespondenceController.CreateCorrespondenceRequestV2(
job.Id, "Me", "Called to follow up.", "Follow-up call", "Call", null, "outbound", null, null, null, null, null, null);
var result = await controller.Create(request, CancellationToken.None);
Assert.IsType<Correspondence>(((CreatedAtActionResult)result.Result!).Value);
var stored = await db.Correspondences.SingleAsync();
Assert.Equal("manual", stored.Provider);
}
}
@@ -288,6 +288,7 @@ public sealed class GmailControllerTests
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync();
Assert.Single(storedMessages);
Assert.Equal("gmail", storedMessages[0].Provider);
gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()), Times.Once);
}
@@ -159,6 +159,7 @@ namespace JobTrackerApi.Controllers
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
Provider = "manual",
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
+15 -187
View File
@@ -13,7 +13,7 @@ namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/gmail")]
[Authorize]
public sealed class GmailController : ControllerBase
public sealed partial class GmailController : ControllerBase
{
private readonly IGmailOAuthService _gmail;
private readonly IGmailJobMatchingService _matching;
@@ -37,77 +37,6 @@ public sealed class GmailController : ControllerBase
private IEmailProvider Email => _providers.Get("gmail")
?? throw new InvalidOperationException("Gmail email provider is not registered.");
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
string Subject,
int Score,
string Confidence,
bool HasImportedMessages,
int ImportedMessageCount,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
int JobApplicationId,
string JobTitle,
string CompanyName,
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
[HttpGet("status")]
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
{
@@ -671,7 +600,9 @@ public sealed class GmailController : ControllerBase
imported++;
}
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
var suggestedJobReviewDecision = await _db.GmailReviewDecisions
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
UpsertReviewDecision(suggestedJobReviewDecision, ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
}
@@ -725,8 +656,9 @@ public sealed class GmailController : ControllerBase
imported++;
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
var reviewDecision = await _db.GmailReviewDecisions
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, "linked", job.Id, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
}
@@ -752,10 +684,11 @@ public sealed class GmailController : ControllerBase
_db.Correspondences.RemoveRange(messages);
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
var reviewDecision = await _db.GmailReviewDecisions
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, nextDecision, null, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
}
@@ -977,6 +910,7 @@ public sealed class GmailController : ControllerBase
GmailAttachmentId = attachment.ExternalAttachmentId,
Inline = attachment.Inline,
})),
Provider = "gmail",
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
@@ -1011,40 +945,6 @@ public sealed class GmailController : ControllerBase
return _matching.BuildJobQueries(job, queryOverride);
}
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!includeSpamTrash)
{
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
if (!decisions.TryGetValue(threadId, out var existing))
@@ -1064,9 +964,11 @@ public sealed class GmailController : ControllerBase
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
// Single-thread upsert: callers acting on exactly one ThreadId should load just that row
// (see the FirstOrDefaultAsync call sites below) rather than every review decision for the
// owner just to scan for one match.
private void UpsertReviewDecision(GmailReviewDecision? existing, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
if (existing is null)
{
existing = new GmailReviewDecision
@@ -1074,7 +976,6 @@ public sealed class GmailController : ControllerBase
OwnerUserId = ownerUserId,
ThreadId = threadId,
};
decisions.Add(existing);
_db.GmailReviewDecisions.Add(existing);
}
@@ -1084,54 +985,6 @@ public sealed class GmailController : ControllerBase
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private static string ToConfidence(int score)
{
return score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
@@ -1166,29 +1019,4 @@ public sealed class GmailController : ControllerBase
return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback";
}
private static string BuildPopupHtml(bool success, string message)
{
var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error";
var title = success ? "Gmail connected" : "Gmail connection failed";
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
return $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Gmail connection</title>
</head>
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
<h2>{title}</h2>
<p>{escaped}</p>
<p>You can close this window.</p>
<script>
if (window.opener) {{
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
+79
View File
@@ -0,0 +1,79 @@
using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers;
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour
// change; these were previously nested inline in the controller file).
public partial class GmailController
{
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
string Subject,
int Score,
string Confidence,
bool HasImportedMessages,
int ImportedMessageCount,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
int JobApplicationId,
string JobTitle,
string CompanyName,
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
}
+116
View File
@@ -0,0 +1,116 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Controllers;
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
// refactor -- no behaviour change). All are static and side-effect free.
public sealed partial class GmailController
{
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!includeSpamTrash)
{
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private static string ToConfidence(int score)
{
return score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private static string BuildPopupHtml(bool success, string message)
{
var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error";
var title = success ? "Gmail connected" : "Gmail connection failed";
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
return $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Gmail connection</title>
</head>
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
<h2>{title}</h2>
<p>{escaped}</p>
<p>You can close this window.</p>
<script>
if (window.opener) {{
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
+6
View File
@@ -13,6 +13,12 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<!-- dotnet-ef design-time tooling requires this on the startup project (not just
JobTrackerBackend, where the DbContext actually lives) since EF Core 6+. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <summary>
/// Intentionally a no-op. The committed ModelSnapshot had drifted far behind the live schema
/// (empty -- see JobTrackerContextModelSnapshot.cs history): every table/column added since
/// the last real migration (2026-03-11) was provisioned exclusively through the idempotent
/// raw-SQL reconciler in StartupInitializationExtensions.cs, including the ASP.NET Identity
/// tables themselves, which have never been created by an EF migration in this repo -- see
/// EnsureIdentityTables' comment ("create Identity tables directly if dotnet ef isn't
/// available"). `dotnet ef migrations add` scaffolded the honest diff against that stale
/// snapshot: full CreateTable/AddColumn operations for schema that already exists on every
/// environment (fresh or established) via that reconciler. Applying that diff for real would
/// throw "table/column already exists" everywhere. This migration exists only to record itself
/// in __EFMigrationsHistory and regenerate JobTrackerContextModelSnapshot.cs to match the
/// current C# model, so `dotnet ef migrations add` produces a real (small) diff for the *next*
/// schema change instead of scaffolding the whole database again. It changes no data or schema.
/// </summary>
public partial class SyncModelSnapshot : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,186 @@ namespace JobTrackerApi.Services;
public static class StartupInitializationExtensions
{
// SQLite-dialect schema helpers. Promoted from local functions to class-level statics so a
// second reconciliation pass can run after Migrate() creates the base tables on a brand-new
// database (see the CoreSchemaReady-adjacent block near the end of InitializeJobTrackerAsync):
// the ad-hoc EnsureColumn calls below no-op on a table that doesn't exist yet, so a genuinely
// fresh boot needs them re-run once Migrate() has created JobApplications/Correspondences.
private static bool HasTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
p.Value = table;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
private static bool HasColumn(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
private static bool HasMigration(DbConnection c, string migrationId)
{
if (!HasTable(c, "__EFMigrationsHistory")) return false;
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
private static void Exec(DbConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
private static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
// Fresh databases won't have the table until EF migrations run.
if (!HasTable(c, table)) return;
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
// Ad-hoc columns/backfills added over time without a matching EF migration (the reason the
// ModelSnapshot drifted -- see the SyncModelSnapshot migration's doc comment). Safe to call
// any number of times against any connection state: every check no-ops if the table or
// column doesn't exist yet or already matches.
private static void ReconcileCoreAppColumns(DbConnection conn)
{
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
if (HasTable(conn, "Correspondences"))
{
Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
}
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
}
// MySQL/MariaDB-dialect equivalents of the helpers above.
private static bool HasMySqlTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
return cmd.ExecuteScalar() is not null;
}
private static bool MySqlColumnExists(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
return cmd.ExecuteScalar() is not null;
}
private static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
{
if (!HasMySqlTable(c, table)) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
// MySQL mirror of ReconcileCoreAppColumns -- same rationale (re-run after Migrate() on a
// brand-new database, where these tables didn't exist yet during the pre-Migrate pass).
private static void ReconcileCoreAppColumnsMySql(DbConnection conn)
{
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
if (HasMySqlTable(conn, "Correspondences"))
{
using (var backfillGmail = conn.CreateCommand())
{
backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
backfillGmail.ExecuteNonQuery();
}
using (var backfillManual = conn.CreateCommand())
{
backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
backfillManual.ExecuteNonQuery();
}
}
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
}
public static Task InitializeJobTrackerAsync(this WebApplication app)
{
// Apply EF migrations on startup (SQLite dev DB lives in the repo).
@@ -130,50 +310,6 @@ public static class StartupInitializationExtensions
using DbConnection conn = db.Database.GetDbConnection();
conn.Open();
static bool HasTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
p.Value = table;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static bool HasColumn(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
static bool HasMigration(DbConnection c, string migrationId)
{
if (!HasTable(c, "__EFMigrationsHistory")) return false;
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static void Exec(DbConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
// Fresh databases won't have the table until EF migrations run.
if (!HasTable(c, table)) return;
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
static void EnsureIdentityTables(DbConnection c)
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available,
@@ -530,33 +666,10 @@ public static class StartupInitializationExtensions
}
// Some dev DBs may not match the "legacy" fingerprint above but still lack
// the ShortSummary column. Ensure it exists unconditionally if missing.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Structured salary fields (EF maps decimal to TEXT on SQLite).
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
// later ad-hoc columns. Ensure them unconditionally if missing (also re-run once
// more after Migrate() below, in case this is a brand-new DB where these tables
// didn't exist yet at this point).
ReconcileCoreAppColumns(conn);
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
@@ -580,18 +693,6 @@ public static class StartupInitializationExtensions
conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlColumnExists(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
return cmd.ExecuteScalar() is not null;
}
static bool MySqlIndexExists(DbConnection c, string table, string indexName)
{
using var cmd = c.CreateCommand();
@@ -604,28 +705,6 @@ public static class StartupInitializationExtensions
return cmd.ExecuteScalar() is not null;
}
static bool HasMySqlTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
return cmd.ExecuteScalar() is not null;
}
static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
{
using var existsCmd = c.CreateCommand();
existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
var ep1 = existsCmd.CreateParameter(); ep1.ParameterName = "@schema"; ep1.Value = c.Database; existsCmd.Parameters.Add(ep1);
var ep2 = existsCmd.CreateParameter(); ep2.ParameterName = "@table"; ep2.Value = table; existsCmd.Parameters.Add(ep2);
if (existsCmd.ExecuteScalar() is null) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
{
@@ -665,52 +744,10 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
ReconcileCoreAppColumnsMySql(conn);
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
@@ -1168,6 +1205,20 @@ public static class StartupInitializationExtensions
app.Logger.LogWarning("Core schema is incomplete after startup initialization. Background services will remain paused until required tables exist.");
return Task.CompletedTask;
}
// On a brand-new database, the ad-hoc-column reconciliation above ran before
// Migrate() created JobApplications/Correspondences, so every EnsureColumn call
// no-opped. Now that CoreSchemaReady confirms the tables exist (created either just
// now by Migrate(), or already, on a prior boot), re-run it -- idempotent, so this is
// free on every boot except the very first one, where it's required.
if (runtimeProvider is "mysql" or "mariadb")
{
ReconcileCoreAppColumnsMySql(conn);
}
else
{
ReconcileCoreAppColumns(conn);
}
}
var readiness = app.Services.GetRequiredService<IStartupReadiness>();
+4
View File
@@ -21,6 +21,10 @@ namespace JobTrackerApi.Models
public string? ExternalTo { get; set; }
public string? ExternalLabelsJson { get; set; }
public string? AttachmentMetadataJson { get; set; }
// Provider discriminator: "gmail" | "microsoft" | "imap" | "manual". Set at the write
// site (import controller or the manual-entry endpoint), not inferred from other fields,
// so it stays correct even for hand-entered rows that happen to carry external-looking data.
public string? Provider { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;
@@ -0,0 +1,243 @@
import React, { useCallback, useEffect, useState } from "react";
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
// MicrosoftGraphController's BuildPopupHtml); IMAP has no OAuth step, so it's a plain credential
// form submitted to POST /api/imap/connect, which verifies the connection before storing it.
export default function EmailProviderConnections() {
const { toast } = useToast();
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null);
const [imapStatus, setImapStatus] = useState<ImapStatus | null>(null);
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
const [imapConnecting, setImapConnecting] = useState(false);
const loadGmailStatus = useCallback(async () => {
try {
const res = await api.get<GmailStatus>("/gmail/status");
setGmailStatus(res.data);
} catch {
setGmailStatus({ connected: false });
}
}, []);
const loadMicrosoftStatus = useCallback(async () => {
try {
const res = await api.get<MicrosoftGraphStatus>("/microsoft-graph/status");
setMicrosoftStatus(res.data);
} catch {
setMicrosoftStatus({ connected: false });
}
}, []);
const loadImapStatus = useCallback(async () => {
try {
const res = await api.get<ImapStatus>("/imap/status");
setImapStatus(res.data);
} catch {
setImapStatus({ connected: false });
}
}, []);
useEffect(() => {
void loadGmailStatus();
void loadMicrosoftStatus();
void loadImapStatus();
}, [loadGmailStatus, loadMicrosoftStatus, loadImapStatus]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source === "jobtracker-gmail-oauth") {
if (data.status === "connected") {
toast(data.message || "Gmail connected.", "success");
void loadGmailStatus();
} else {
toast(data.message || "Gmail connection failed.", "error");
}
} else if (data?.source === "jobtracker-microsoft-oauth") {
if (data.status === "connected") {
toast(data.message || "Outlook connected.", "success");
void loadMicrosoftStatus();
} else {
toast(data.message || "Outlook connection failed.", "error");
}
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadGmailStatus, loadMicrosoftStatus, toast]);
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
try {
const res = await api.get<{ url: string }>(connectUrlPath);
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) toast("Your browser blocked the connect popup. Allow popups and try again.", "error");
} catch (error) {
toast(getApiErrorMessage(error, `Failed to start ${providerLabel} connection.`), "error");
}
};
const disconnect = async (path: string, reload: () => Promise<void>, providerLabel: string) => {
try {
await api.delete(path);
await reload();
toast(`${providerLabel} disconnected.`, "success");
} catch (error) {
toast(getApiErrorMessage(error, `Failed to disconnect ${providerLabel}.`), "error");
}
};
const connectImap = async () => {
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
toast("Host, username, and password are required.", "error");
return;
}
setImapConnecting(true);
try {
await api.post("/imap/connect", imapForm);
setImapForm((prev) => ({ ...prev, password: "" }));
await loadImapStatus();
toast("IMAP account connected.", "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to connect IMAP account."), "error");
} finally {
setImapConnecting(false);
}
};
return (
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>Linked email accounts</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Connect a mailbox so recruiter correspondence can be linked to jobs automatically.
</Typography>
<Stack spacing={2}>
<ProviderRow
label="Gmail"
connected={Boolean(gmailStatus?.connected)}
address={gmailStatus?.gmailAddress ?? null}
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
/>
<Divider />
<ProviderRow
label="Outlook / Microsoft 365"
connected={Boolean(microsoftStatus?.connected)}
address={microsoftStatus?.mailAddress ?? null}
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
/>
<Divider />
<Box>
<ProviderRow
label="Other (IMAP)"
connected={Boolean(imapStatus?.connected)}
address={imapStatus?.username ?? null}
onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")}
/>
{!imapStatus?.connected && (
<Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}>
<TextField
size="small"
label="IMAP host"
placeholder="imap.example.com"
value={imapForm.host}
onChange={(e) => setImapForm((prev) => ({ ...prev, host: e.target.value }))}
/>
<TextField
size="small"
label="Port"
type="number"
value={imapForm.port}
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
/>
<TextField
size="small"
label="Username"
value={imapForm.username}
onChange={(e) => setImapForm((prev) => ({ ...prev, username: e.target.value }))}
sx={{ gridColumn: "1 / -1" }}
/>
<TextField
size="small"
label="Password"
type="password"
value={imapForm.password}
onChange={(e) => setImapForm((prev) => ({ ...prev, password: e.target.value }))}
sx={{ gridColumn: "1 / -1" }}
/>
<FormControlLabel
sx={{ gridColumn: "1 / -1" }}
control={<Checkbox checked={imapForm.useSsl} onChange={(e) => setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />}
label="Use SSL/TLS"
/>
<Button
variant="contained"
onClick={connectImap}
disabled={imapConnecting}
sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
>
{imapConnecting ? "Connecting…" : "Connect IMAP account"}
</Button>
</Box>
)}
</Box>
</Stack>
</Paper>
);
}
function ProviderRow({
label,
connected,
address,
onConnect,
onDisconnect,
}: {
label: string;
connected: boolean;
address: string | null;
onConnect?: () => void;
onDisconnect: () => void;
}) {
return (
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box>
<Typography sx={{ fontWeight: 700 }}>{label}</Typography>
{connected ? (
<Chip
size="small"
icon={<CheckCircleIcon fontSize="small" />}
color="success"
variant="outlined"
label={address || "Connected"}
sx={{ mt: 0.5 }}
/>
) : (
<Typography variant="caption" sx={{ color: "text.secondary" }}>Not connected</Typography>
)}
</Box>
{connected ? (
<Button size="small" variant="outlined" color="error" onClick={onDisconnect}>Disconnect</Button>
) : (
onConnect && <Button size="small" variant="outlined" onClick={onConnect}>Connect</Button>
)}
</Stack>
);
}
@@ -22,6 +22,7 @@ import { useNavigate } from "react-router-dom";
import { JobTableColumns } from "./JobTable";
import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import EmailProviderConnections from "./EmailProviderConnections";
import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard";
@@ -338,6 +339,9 @@ export default function SettingsView({
<TabPanel value={tab} index={3}>
<AuthStatusCard />
<GoogleAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
</TabPanel>
<TabPanel value={tab} index={4}>
@@ -0,0 +1,72 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ToastProvider } from "./toast";
import { api } from "./api";
import EmailProviderConnections from "./components/EmailProviderConnections";
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderComponent() {
return render(
<ToastProvider>
<EmailProviderConnections />
</ToastProvider>,
);
}
describe("EmailProviderConnections", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => {
mockedApi.get.mockImplementation((path: string) => {
if (path === "/gmail/status") return Promise.resolve({ data: { connected: true, gmailAddress: "me@gmail.test" } });
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
return Promise.reject(new Error("unexpected path"));
});
renderComponent();
expect(await screen.findByText("me@gmail.test")).toBeInTheDocument();
expect(screen.getByLabelText("IMAP host")).toBeInTheDocument();
expect(screen.getAllByText("Not connected").length).toBeGreaterThan(0);
});
it("submits IMAP connect form and reloads status on success", async () => {
mockedApi.get.mockImplementation((path: string) => {
if (path === "/gmail/status") return Promise.resolve({ data: { connected: false } });
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
return Promise.reject(new Error("unexpected path"));
});
mockedApi.post.mockResolvedValueOnce({ data: { username: "user@example.test" } });
renderComponent();
await screen.findByLabelText("IMAP host");
await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test");
await userEvent.type(screen.getByLabelText("Username"), "user@example.test");
await userEvent.type(screen.getByLabelText("Password"), "secret");
await userEvent.click(screen.getByRole("button", { name: /connect imap account/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({
host: "imap.example.test",
username: "user@example.test",
password: "secret",
})));
});
});
+29
View File
@@ -384,6 +384,35 @@ export interface GmailStatus {
lastSyncError?: string | null;
}
export interface MicrosoftGraphStatus {
connected: boolean;
mailAddress?: string | null;
connectedAt?: string;
lastSyncedAt?: string;
lastSyncAttemptedAt?: string;
lastSyncSucceededAt?: string;
lastSyncMode?: string | null;
lastSyncSource?: string | null;
lastSyncStatus?: string | null;
lastSyncError?: string | null;
}
export interface ImapStatus {
connected: boolean;
host?: string | null;
port?: number | null;
useSsl?: boolean | null;
username?: string | null;
connectedAt?: string;
lastSyncedAt?: string;
lastSyncAttemptedAt?: string;
lastSyncSucceededAt?: string;
lastSyncMode?: string | null;
lastSyncSource?: string | null;
lastSyncStatus?: string | null;
lastSyncError?: string | null;
}
export interface GmailManualSyncResult {
queriesRun: number;
candidateThreadCount: number;