From dca5daa1a2f7b16d30cf58b07b4c8cb2465f059d Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 24 Aug 2026 20:21:09 +0200 Subject: [PATCH] fix(app): harden account and workflow state --- .../BackgroundWorkerTenantTests.cs | 39 +++++ JobTrackerApi.Tests/JobImportServiceTests.cs | 53 ++++++ .../NotificationSettingsControllerTests.cs | 67 ++++++++ JobTrackerApi.Tests/UsersControllerTests.cs | 46 +++++ .../Controllers/JobApplicationsController.cs | 19 ++- .../NotificationSettingsController.cs | 34 ++++ JobTrackerApi/Controllers/UsersController.cs | 7 + JobTrackerApi/Data/JobTrackerContext.cs | 4 + ...260824090000_AddNotificationPreferences.cs | 27 +++ .../JobTrackerContextModelSnapshot.cs | 5 + JobTrackerApi/Models/ApplicationUser.cs | 1 + .../Services/FollowUpReminderHostedService.cs | 2 +- .../Services/JobImport/JobImportService.cs | 24 ++- job-tracker-ui/src/App.tsx | 6 +- .../src/account-scoped-state.test.tsx | 62 +++++++ .../src/application-workflow-assist.test.tsx | 57 +++++++ job-tracker-ui/src/auth.ts | 5 + .../src/components/ApplicationAssets.tsx | 18 ++ .../components/ApplicationWorkflowAssist.tsx | 158 ++++++++++++++++++ job-tracker-ui/src/components/JobTable.tsx | 17 +- .../src/components/SavedViewsMenu.tsx | 18 +- .../src/components/SettingsView.tsx | 78 ++++----- job-tracker-ui/src/confirm.tsx | 14 +- job-tracker-ui/src/hooks/useCompanies.ts | 36 +++- job-tracker-ui/src/hooks/useViewResource.ts | 6 + job-tracker-ui/src/settings-view.test.tsx | 10 +- job-tracker-ui/src/use-view-resource.test.tsx | 36 ++++ .../src/views/ApplicationWorkspacePage.tsx | 3 + job-tracker-ui/src/views/ProfilePage.tsx | 8 +- .../src/workflow-trust-signals.test.tsx | 17 +- tools/summarizer/app.py | 8 +- tools/summarizer/tests/test_app.py | 12 ++ 32 files changed, 811 insertions(+), 86 deletions(-) create mode 100644 JobTrackerApi.Tests/NotificationSettingsControllerTests.cs create mode 100644 JobTrackerApi/Controllers/NotificationSettingsController.cs create mode 100644 JobTrackerApi/Migrations/20260824090000_AddNotificationPreferences.cs create mode 100644 job-tracker-ui/src/account-scoped-state.test.tsx create mode 100644 job-tracker-ui/src/application-workflow-assist.test.tsx create mode 100644 job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx create mode 100644 job-tracker-ui/src/use-view-resource.test.tsx diff --git a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs index 0577d41..672671d 100644 --- a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs +++ b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs @@ -290,6 +290,45 @@ public sealed class BackgroundWorkerTenantTests Assert.All(jobs, job => Assert.Equal(FixedNow.DateTime, job.LastReminderEmailSentAt)); } + [Fact] + public async Task Reminder_worker_respects_each_owners_persisted_email_opt_out() + { + var email = new Mock(); + await using var fixture = await Fixture.CreateAsync( + new Dictionary + { + ["Workers:FollowUpRemindersEnabled"] = "true", + ["Email:FollowUpReminders:Enabled"] = "true", + ["App:PublicBaseUrl"] = "http://localhost:3000", + }, + services => services.AddSingleton(email.Object)); + await fixture.SeedJobsAsync(includeUsers: true); + await using (var scope = fixture.Provider.CreateAsyncScope()) + { + var user = await scope.ServiceProvider.GetRequiredService>().FindByIdAsync("user-2"); + Assert.NotNull(user); + user!.EmailFollowUpRemindersEnabled = false; + Assert.True((await scope.ServiceProvider.GetRequiredService>().UpdateAsync(user)).Succeeded); + } + + var worker = new FollowUpReminderHostedService( + fixture.Runner, + fixture.Configuration, + NullLogger.Instance, + Mock.Of(), + ExternalOrigin.FromConfiguration(fixture.Configuration), + new MutableTimeProvider(FixedNow)); + Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); + + email.Verify(x => x.SendAsync("one@example.test", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + email.Verify(x => x.SendAsync("two@example.test", It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + await using var verificationScope = fixture.Provider.CreateAsyncScope(); + var jobs = await verificationScope.ServiceProvider.GetRequiredService().JobApplications + .IgnoreQueryFilters().AsNoTracking().OrderBy(job => job.OwnerUserId).ToListAsync(); + Assert.Equal(FixedNow.DateTime, jobs[0].LastReminderEmailSentAt); + Assert.Null(jobs[1].LastReminderEmailSentAt); + } + [Fact] public async Task Rules_worker_uses_injected_clock_across_threshold_and_restart() { diff --git a/JobTrackerApi.Tests/JobImportServiceTests.cs b/JobTrackerApi.Tests/JobImportServiceTests.cs index fa9a730..6af6c19 100644 --- a/JobTrackerApi.Tests/JobImportServiceTests.cs +++ b/JobTrackerApi.Tests/JobImportServiceTests.cs @@ -64,6 +64,26 @@ public sealed class JobImportServiceTests Assert.Equal("No JobPosting schema found.", result.Error); } + [Fact] + public async Task Preview_stops_reading_a_chunked_response_at_the_download_limit() + { + var resolver = new Mock(); + resolver.Setup(x => x.ResolveAsync("example.com", It.IsAny())) + .ReturnsAsync([IPAddress.Parse("93.184.216.34")]); + var contentStream = new GeneratedStream(10_000_000); + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(contentStream) + }); + var service = CreateService(resolver.Object, handler); + + var result = await service.PreviewAsync("https://example.com/job", CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal("fetch", result.Parser); + Assert.InRange(contentStream.BytesRead, 4_000_001, 4_065_536); + } + private static JobImportService CreateService(IHostAddressResolver resolver, HttpMessageHandler? handler = null) { handler ??= new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) @@ -95,4 +115,37 @@ public sealed class JobImportServiceTests protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(_handler(request)); } + + private sealed class GeneratedStream(long length) : Stream + { + private long _position; + public long BytesRead { get; private set; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) + { + var read = (int)Math.Min(count, length - _position); + if (read <= 0) return 0; + Array.Fill(buffer, (byte)'x', offset, read); + _position += read; + BytesRead += read; + return read; + } + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = (int)Math.Min(buffer.Length, length - _position); + if (read <= 0) return ValueTask.FromResult(0); + buffer.Span[..read].Fill((byte)'x'); + _position += read; + BytesRead += read; + return ValueTask.FromResult(read); + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } } diff --git a/JobTrackerApi.Tests/NotificationSettingsControllerTests.cs b/JobTrackerApi.Tests/NotificationSettingsControllerTests.cs new file mode 100644 index 0000000..e7cdc9c --- /dev/null +++ b/JobTrackerApi.Tests/NotificationSettingsControllerTests.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class NotificationSettingsControllerTests +{ + [Fact] + public async Task Get_returns_the_authenticated_users_preference() + { + var user = new ApplicationUser { Id = "user-1", EmailFollowUpRemindersEnabled = false }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + + var result = await CreateController(users).Get(); + + var ok = Assert.IsType(result.Result); + var settings = Assert.IsType(ok.Value); + Assert.False(settings.EmailFollowUpRemindersEnabled); + } + + [Fact] + public async Task Put_persists_the_authenticated_users_preference() + { + var user = new ApplicationUser { Id = "user-1", EmailFollowUpRemindersEnabled = true }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + + var result = await CreateController(users).Put(new(false)); + + var ok = Assert.IsType(result.Result); + var settings = Assert.IsType(ok.Value); + Assert.False(settings.EmailFollowUpRemindersEnabled); + users.Verify(x => x.UpdateAsync(It.Is(candidate => !candidate.EmailFollowUpRemindersEnabled)), Times.Once); + } + + [Fact] + public async Task Put_does_not_update_when_the_request_has_no_authenticated_user() + { + var users = TestHostFactory.CreateUserManager(); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var result = await CreateController(users).Put(new(false)); + + Assert.IsType(result.Result); + users.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + private static NotificationSettingsController CreateController(Mock> users) => new(users.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "user-1")], "local")) + } + } + }; +} diff --git a/JobTrackerApi.Tests/UsersControllerTests.cs b/JobTrackerApi.Tests/UsersControllerTests.cs index cf0a51a..168b2d0 100644 --- a/JobTrackerApi.Tests/UsersControllerTests.cs +++ b/JobTrackerApi.Tests/UsersControllerTests.cs @@ -57,6 +57,51 @@ public sealed class UsersControllerTests users.Verify(x => x.RemoveFromRolesAsync(admin, It.IsAny>()), Times.Once); } + [Fact] + public async Task SetRoles_revokes_existing_sessions_and_trusted_devices_when_roles_change() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns("admin-1"); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new JobTrackerContext(options, currentUser.Object); + await db.Database.EnsureCreatedAsync(); + + var member = User("user-1"); + db.Users.Add(member); + db.UserSessions.Add(new UserSession + { + Id = "member-session", + UserId = member.Id, + CreatedAtUtc = DateTimeOffset.UtcNow, + LastSeenAtUtc = DateTimeOffset.UtcNow, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(1) + }); + db.TrustedDevices.Add(new TrustedDevice + { + UserId = member.Id, + TokenHash = "device-hash", + CreatedAtUtc = DateTimeOffset.UtcNow, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30) + }); + await db.SaveChangesAsync(); + + var users = TestHostFactory.CreateUserManager(member); + users.Setup(x => x.GetRolesAsync(member)).ReturnsAsync([]); + users.Setup(x => x.AddToRoleAsync(member, "Admin")).ReturnsAsync(IdentityResult.Success); + var controller = CreateController(users, "admin-1", db); + + var result = await controller.SetRoles(member.Id, new UsersController.SetRolesRequest(["Admin"]), CancellationToken.None); + + Assert.IsType(result); + var session = await db.UserSessions.IgnoreQueryFilters().SingleAsync(x => x.Id == "member-session"); + Assert.NotNull(session.RevokedAtUtc); + Assert.Empty(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == member.Id).ToListAsync()); + } + [Fact] public async Task Delete_rejects_deleting_the_final_administrator() { @@ -159,6 +204,7 @@ public sealed class UsersControllerTests new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), new NullLogger>()); + roles.Setup(manager => manager.RoleExistsAsync(It.IsAny())).ReturnsAsync(true); var controller = new UsersController( users.Object, diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index d52233a..29774b4 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -473,6 +473,7 @@ Canonical profile: [FromQuery] int? companyId = null, [FromQuery] string? location = null, [FromQuery] bool needsFollowUp = false, + [FromQuery] string? readiness = null, [FromQuery] bool includeDeleted = false, [FromQuery] bool deletedOnly = false, [FromQuery] string? sortBy = null, @@ -482,6 +483,9 @@ Canonical profile: { if (page < 1) page = 1; if (pageSize is not (15 or 20 or 25)) pageSize = 15; + var readinessFilter = (readiness ?? string.Empty).Trim().ToLowerInvariant(); + if (readinessFilter is not ("" or "needs-work" or "interview")) + return BadRequest("Readiness must be 'needs-work' or 'interview'."); var query = _db.JobApplications .AsNoTracking() @@ -542,16 +546,25 @@ Canonical profile: var dirDesc = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase); var key = (sortBy ?? "dateApplied").Trim(); - if (needsFollowUp) + if (needsFollowUp || readinessFilter.Length > 0) { - // NeedsFollowUp depends on rules + last correspondence date; evaluate in memory so filtering is correct. + // Both filters depend on rule evaluation and workflow signals that include normalized + // note content, so evaluate before pagination to keep totals/pages correct. var pre = await query.ToListAsync(cancellationToken); var filtered = new List(); foreach (var j in pre) { lastMsg.TryGetValue(j.Id, out var lm); var d = RulesEngine.Evaluate(settings, j, now, lm); - if (d.NeedsFollowUp) filtered.Add(j); + var signal = BuildWorkflowSignal(j, d); + var matchesFollowUp = !needsFollowUp || d.NeedsFollowUp; + var matchesReadiness = readinessFilter switch + { + "interview" => signal.NeedsInterviewPrep, + "needs-work" => signal.HasPackageGap || signal.NeedsInterviewPrep, + _ => true, + }; + if (matchesFollowUp && matchesReadiness) filtered.Add(j); } filtered = key switch diff --git a/JobTrackerApi/Controllers/NotificationSettingsController.cs b/JobTrackerApi/Controllers/NotificationSettingsController.cs new file mode 100644 index 0000000..7ecf851 --- /dev/null +++ b/JobTrackerApi/Controllers/NotificationSettingsController.cs @@ -0,0 +1,34 @@ +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/notification-settings")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class NotificationSettingsController(UserManager users) : ControllerBase +{ + public sealed record NotificationSettingsDto(bool EmailFollowUpRemindersEnabled); + public sealed record SaveNotificationSettingsRequest(bool EmailFollowUpRemindersEnabled); + + [HttpGet] + public async Task> Get() + { + var user = await users.GetUserAsync(User); + return user is null ? Unauthorized() : Ok(new NotificationSettingsDto(user.EmailFollowUpRemindersEnabled)); + } + + [HttpPut] + public async Task> Put([FromBody] SaveNotificationSettingsRequest request) + { + var user = await users.GetUserAsync(User); + if (user is null) return Unauthorized(); + user.EmailFollowUpRemindersEnabled = request.EmailFollowUpRemindersEnabled; + var result = await users.UpdateAsync(user); + if (!result.Succeeded) + return Problem("Notification settings could not be saved.", statusCode: StatusCodes.Status500InternalServerError); + return Ok(new NotificationSettingsDto(user.EmailFollowUpRemindersEnabled)); + } +} diff --git a/JobTrackerApi/Controllers/UsersController.cs b/JobTrackerApi/Controllers/UsersController.cs index 0a30a3d..c7906ad 100644 --- a/JobTrackerApi/Controllers/UsersController.cs +++ b/JobTrackerApi/Controllers/UsersController.cs @@ -179,6 +179,13 @@ public sealed class UsersController : ControllerBase if (!removeRoles.Succeeded) return IdentityFailure(removeRoles); } + // Role claims are embedded in issued JWTs. Without revoking the target user's live + // sessions, a removed Admin role would remain effective until those tokens expired. + // The application DI path always supplies the context; the null branch only supports + // isolated controller construction in older tests. + if (_db is not null && (toAdd.Count > 0 || toRemove.Count > 0)) + await SessionRevocation.RevokeAllAsync(_db, u.Id, trustedDeviceHashToKeep: null, cancellationToken); + return NoContent(); } diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index 195b9bb..f6c97aa 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -88,6 +88,10 @@ namespace JobTrackerApi.Data .HasMaxLength(32) .HasDefaultValue(AccountDeletionStatuses.Active); + modelBuilder.Entity() + .Property(x => x.EmailFollowUpRemindersEnabled) + .HasDefaultValue(true); + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.OwnerKey).HasMaxLength(64); modelBuilder.Entity().Property(x => x.RequestedByUserId).HasMaxLength(255); diff --git a/JobTrackerApi/Migrations/20260824090000_AddNotificationPreferences.cs b/JobTrackerApi/Migrations/20260824090000_AddNotificationPreferences.cs new file mode 100644 index 0000000..ebe740a --- /dev/null +++ b/JobTrackerApi/Migrations/20260824090000_AddNotificationPreferences.cs @@ -0,0 +1,27 @@ +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations; + +[DbContext(typeof(JobTrackerContext))] +[Migration("20260824090000_AddNotificationPreferences")] +public sealed class AddNotificationPreferences : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EmailFollowUpRemindersEnabled", + table: "AspNetUsers", + type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "tinyint(1)" : "INTEGER", + nullable: false, + defaultValue: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "EmailFollowUpRemindersEnabled", table: "AspNetUsers"); + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index e2804bd..89c1964 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -393,6 +393,11 @@ namespace JobTrackerApi.Migrations b.Property("ExternalAiProcessingAllowed") .HasColumnType("INTEGER"); + b.Property("EmailFollowUpRemindersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("FirstName") .HasColumnType("TEXT"); diff --git a/JobTrackerApi/Models/ApplicationUser.cs b/JobTrackerApi/Models/ApplicationUser.cs index 72f1c94..ae25732 100644 --- a/JobTrackerApi/Models/ApplicationUser.cs +++ b/JobTrackerApi/Models/ApplicationUser.cs @@ -32,6 +32,7 @@ public sealed class ApplicationUser : IdentityUser public DateTime? StripeLastEventCreatedUtc { get; set; } public bool AiEnabled { get; set; } = true; public bool ExternalAiProcessingAllowed { get; set; } + public bool EmailFollowUpRemindersEnabled { get; set; } = true; public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active; public DateTimeOffset? DeletionRequestedAtUtc { get; set; } } diff --git a/JobTrackerApi/Services/FollowUpReminderHostedService.cs b/JobTrackerApi/Services/FollowUpReminderHostedService.cs index 4e2d11f..505c367 100644 --- a/JobTrackerApi/Services/FollowUpReminderHostedService.cs +++ b/JobTrackerApi/Services/FollowUpReminderHostedService.cs @@ -72,7 +72,7 @@ public sealed class FollowUpReminderHostedService( if (!decision.NeedsFollowUp && !upcoming) continue; var owner = await users.FindByIdAsync(job.OwnerUserId); - if (owner is null || !owner.EmailConfirmed || string.IsNullOrWhiteSpace(owner.Email)) continue; + if (owner is null || !owner.EmailFollowUpRemindersEnabled || !owner.EmailConfirmed || string.IsNullOrWhiteSpace(owner.Email)) continue; var followMode = SuggestFollowUpMode(job.Status); var detailsUrl = externalOrigin.BuildPath($"/jobs?open={job.Id}&tab=4&followMode={Uri.EscapeDataString(followMode)}"); diff --git a/JobTrackerApi/Services/JobImport/JobImportService.cs b/JobTrackerApi/Services/JobImport/JobImportService.cs index dd69ef3..97c0796 100644 --- a/JobTrackerApi/Services/JobImport/JobImportService.cs +++ b/JobTrackerApi/Services/JobImport/JobImportService.cs @@ -11,6 +11,7 @@ namespace JobTrackerApi.Services.JobImport; public sealed class JobImportService { + private const int MaxDownloadBytes = 4_000_000; private readonly IHttpClientFactory _httpClientFactory; private readonly UniversalJobParser _universal; private readonly IEnumerable _plugins; @@ -124,10 +125,25 @@ public sealed class JobImportService // Still read: many sites omit content-type. Best-effort. } - // Cap to avoid huge downloads. - var bytes = await res.Content.ReadAsByteArrayAsync(cancellationToken); - if (bytes.Length > 4_000_000) return null; - return System.Text.Encoding.UTF8.GetString(bytes); + // Enforce the cap while streaming. Checking after ReadAsByteArrayAsync allowed an + // arbitrarily large (and automatically decompressed) response to consume memory first. + if (res.Content.Headers.ContentLength is > MaxDownloadBytes) return null; + await using var body = await res.Content.ReadAsStreamAsync(cancellationToken); + using var bounded = new MemoryStream(capacity: res.Content.Headers.ContentLength is > 0 + ? (int)Math.Min(res.Content.Headers.ContentLength.Value, MaxDownloadBytes) + : 0); + var buffer = new byte[64 * 1024]; + var total = 0; + while (true) + { + var remainingWithSentinel = MaxDownloadBytes - total + 1; + var read = await body.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, remainingWithSentinel)), cancellationToken); + if (read == 0) break; + total += read; + if (total > MaxDownloadBytes) return null; + await bounded.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + return System.Text.Encoding.UTF8.GetString(bounded.GetBuffer(), 0, total); } private async Task ValidateUrlAsync(string? url, CancellationToken cancellationToken) diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 5a9e8ea..b526a5a 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -15,6 +15,7 @@ import ShieldIcon from "@mui/icons-material/Shield"; import SearchIcon from "@mui/icons-material/Search"; import MemoryIcon from "@mui/icons-material/Memory"; import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; +import MailOutlineIcon from "@mui/icons-material/MailOutline"; import { Navigate, Route, Routes, useLocation, useNavigate, useParams, createBrowserRouter, RouterProvider } from "react-router-dom"; @@ -94,11 +95,11 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] { if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"]; if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"]; if (path.startsWith("/trash")) return [t("home"), t("trash")]; + if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"]; if (path.startsWith("/settings")) return [t("home"), t("settings")]; if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")]; if (path.startsWith("/career/builder")) return [t("home"), "Career Workspace", "CV Builder"]; if (path.startsWith("/career")) return [t("home"), "Career Workspace"]; - if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"]; if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")]; if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")]; if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")]; @@ -117,11 +118,11 @@ function titleFor(path: string, t: (k: any) => string): string { if (path.startsWith("/correspondence/review")) return "Gmail review queue"; if (path.startsWith("/correspondence")) return "Correspondence inbox"; if (path.startsWith("/trash")) return t("trash"); + if (path.startsWith("/settings/connected-accounts")) return "Connected accounts"; if (path.startsWith("/settings")) return t("settings"); if (path.startsWith("/profile")) return t("profile"); if (path.startsWith("/career/builder")) return "CV Builder"; if (path.startsWith("/career")) return "Career Workspace"; - if (path.startsWith("/settings/connected-accounts")) return "Connected accounts"; if (path.startsWith("/admin/audit")) return t("auditLog"); if (path.startsWith("/admin/users")) return t("users"); if (path.startsWith("/admin/system")) return t("systemStatus"); @@ -282,6 +283,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo { to: "/reminders", label: t("reminders"), icon: , badgeCount: reminderCount, section: t("manage") }, { to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") }, { to: "/companies", label: t("companies"), icon: , section: t("manage") }, + { to: "/correspondence", label: t("correspondenceInbox"), icon: , section: t("manage") }, { to: "/career", label: "Career Workspace", icon: , section: t("manage") }, { to: "/career/builder", label: "CV Builder", icon: , section: t("manage") }, { to: "/trash", label: t("trash"), icon: , section: t("manage") }, diff --git a/job-tracker-ui/src/account-scoped-state.test.tsx b/job-tracker-ui/src/account-scoped-state.test.tsx new file mode 100644 index 0000000..aafe970 --- /dev/null +++ b/job-tracker-ui/src/account-scoped-state.test.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { api } from "./api"; +import { setAuthUserKey } from "./auth"; +import SavedViewsMenu from "./components/SavedViewsMenu"; +import { useCompanies } from "./hooks/useCompanies"; +import { I18nProvider } from "./i18n/I18nProvider"; + +const mockedApi = api as jest.Mocked; + +function CompaniesProbe() { + const { companies } = useCompanies(); + return
{companies.map((company) => company.name).join(", ") || "No companies"}
; +} + +describe("account-scoped browser state", () => { + beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); + }); + + it("keeps saved views private to the account that created them", async () => { + setAuthUserKey("user-a", false); + const first = render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Saved views" })); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "User A interviews" } }); + fireEvent.click(screen.getByRole("button", { name: "Save current" })); + expect(screen.getByText("User A interviews")).toBeInTheDocument(); + first.unmount(); + + setAuthUserKey("user-b", false); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Saved views" })); + + expect(screen.queryByText("User A interviews")).not.toBeInTheDocument(); + expect(screen.getByText("No saved views yet.")).toBeInTheDocument(); + }); + + it("does not show a previous account's cached companies after account switching", async () => { + setAuthUserKey("user-a", false); + mockedApi.get.mockResolvedValueOnce({ data: [{ id: 1, name: "User A Company" }] } as any); + render(); + expect(await screen.findByText("User A Company")).toBeInTheDocument(); + + mockedApi.get.mockResolvedValueOnce({ data: [{ id: 2, name: "User B Company" }] } as any); + setAuthUserKey("user-b", false); + + await waitFor(() => expect(screen.getByText("User B Company")).toBeInTheDocument()); + expect(screen.queryByText("User A Company")).not.toBeInTheDocument(); + }); +}); diff --git a/job-tracker-ui/src/application-workflow-assist.test.tsx b/job-tracker-ui/src/application-workflow-assist.test.tsx new file mode 100644 index 0000000..63befec --- /dev/null +++ b/job-tracker-ui/src/application-workflow-assist.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { api } from './api'; +import { AccountPlanProvider } from './accountPlan'; +import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from './components/ApplicationWorkflowAssist'; +import { I18nProvider } from './i18n/I18nProvider'; +import { ToastProvider } from './toast'; + +jest.mock('./api', () => ({ + api: { get: jest.fn(), post: jest.fn(), patch: jest.fn() }, + getApiErrorMessage: (_error: unknown, fallback?: string) => fallback || 'Request failed.', +})); + +const mockedApi = api as jest.Mocked; + +beforeEach(() => jest.clearAllMocks()); + +test('recruiter status suggestions require an explicit apply action', async () => { + const applied = jest.fn(); + mockedApi.get.mockResolvedValue({ data: { hasSuggestion: true, currentStatus: 'Applied', suggestedStatus: 'Interview' } } as any); + mockedApi.patch.mockResolvedValue({ data: {} } as any); + render(); + + const button = await screen.findByRole('button', { name: 'Apply Interview' }); + expect(mockedApi.patch).not.toHaveBeenCalled(); + fireEvent.click(button); + await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' })); + expect(applied).toHaveBeenCalled(); +}); + +test('strategy snapshot shows saved output and queues regeneration only on request', async () => { + mockedApi.get.mockImplementation((url: string) => { + if (url.endsWith('/focus-plan')) return Promise.resolve({ data: { + strategicSummary: 'Lead with delivery evidence.', + immediatePriorities: ['Tailor the summary'], + proofPointsToLeadWith: ['Reduced lead time'], + cvBulletIdeas: ['Quantify the migration'], + coverLetterAngles: ['Public-service impact'], + followUpApproach: ['Follow up after five days'], + } } as any); + return Promise.reject(new Error('no operation')); + }); + mockedApi.post.mockResolvedValue({ data: { + created: true, + statusUrl: '/operations/op-1', + operation: { id: 'op-1', taskType: 'focus-plan', status: 'queued', createdAtUtc: '', canCancel: true, canRetry: false }, + } } as any); + + render(); + expect(await screen.findByText('Lead with delivery evidence.')).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Regenerate' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/focus-plan/operations', { attachmentIds: null })); + expect(await screen.findByText('queued')).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts index c4cbb1f..29de756 100644 --- a/job-tracker-ui/src/auth.ts +++ b/job-tracker-ui/src/auth.ts @@ -72,6 +72,11 @@ export function getAuthUserKey(): string { return safeGet(window.localStorage, AUTH_USER_KEY) ?? "anon"; } +export function getUserScopedStorageKey(baseKey: string, userKey = getAuthUserKey()): string { + const normalizedUser = userKey.trim() || "anon"; + return `${baseKey}:${encodeURIComponent(normalizedUser)}`; +} + export function setAuthUserKey(value: string | null | undefined, emit = true) { const previous = getAuthUserKey(); const next = typeof value === "string" ? value.trim() : ""; diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index bcd1aa5..e71fcee 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -12,6 +12,7 @@ import { getApiErrorMessage } from "../api"; import { ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi, } from "../applicationWorkspace"; +import { cvBuilderApi } from "../cvBuilder"; // Phase 5.4 — Application Assets sections for the workspace. // @@ -100,6 +101,20 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { const attached = data?.attachedVariantId ?? ""; + const duplicateForJob = async () => { + if (!data?.attachedVariantId) return; + setBusy(true); + try { + const copy = await cvBuilderApi.duplicate(data.attachedVariantId, `${data.attachedVariantName || "CV"} — tailored copy`); + setData(await applicationAssetsApi.attachVariant(jobId, copy.id)); + window.location.assign(`/career/builder/${copy.id}`); + } catch (err) { + setError(getApiErrorMessage(err, "Could not create a tailored CV copy.")); + } finally { + setBusy(false); + } + }; + return ( Edit, preview and export + diff --git a/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx b/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx new file mode 100644 index 0000000..3471890 --- /dev/null +++ b/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; + +import { api, getApiErrorMessage } from "../api"; +import { useAccountPlan } from "../accountPlan"; +import type { FocusPlanResponse, StatusSuggestion, StrategySnapshotOperationResponse, UserOperation } from "../types"; +import { useToast } from "../toast"; +import { DraftCard, ListCard, TwoColumnSection } from "./JobDetailsPanels"; + +const terminal = (status: UserOperation["status"]) => ["succeeded", "failed", "cancelled"].includes(status); + +export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: number; onApplied: () => void }) { + const [suggestion, setSuggestion] = useState(null); + const [busy, setBusy] = useState(false); + const { toast } = useToast(); + + useEffect(() => { + let active = true; + api.get(`/jobapplications/${jobId}/status-suggestion`) + .then(({ data }) => { if (active) setSuggestion(data.hasSuggestion ? data : null); }) + .catch(() => { if (active) setSuggestion(null); }); + return () => { active = false; }; + }, [jobId]); + + if (!suggestion?.suggestedStatus) return null; + + const apply = async () => { + setBusy(true); + try { + await api.patch(`/jobapplications/${jobId}/status`, { status: suggestion.suggestedStatus }); + setSuggestion(null); + onApplied(); + toast("Application status updated from the latest message.", "success"); + } catch (error) { + toast(getApiErrorMessage(error, "Could not apply the suggested status."), "error"); + } finally { + setBusy(false); + } + }; + + return ( + void apply()}>Apply {suggestion.suggestedStatus}} + > + A recent recruiter message suggests moving this application from {suggestion.currentStatus || "its current stage"} to {suggestion.suggestedStatus}. + + ); +} + +export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { + const { canUseAi } = useAccountPlan(); + const { toast } = useToast(); + const [plan, setPlan] = useState(null); + const [operation, setOperation] = useState(null); + const [loading, setLoading] = useState(true); + const announced = useRef(null); + + const loadPlan = useCallback(async () => { + try { + const { data } = await api.get(`/jobapplications/${jobId}/focus-plan`); + setPlan(data); + } catch { + setPlan(null); + } + }, [jobId]); + + useEffect(() => { + let active = true; + setLoading(true); + Promise.all([ + loadPlan(), + api.get(`/jobapplications/${jobId}/focus-plan/operation`) + .then(({ data }) => { if (active) setOperation(data); }) + .catch(() => { if (active) setOperation(null); }), + ]).finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [jobId, loadPlan]); + + useEffect(() => { + if (!operation || terminal(operation.status)) return; + const timer = window.setTimeout(() => { + api.get(`/operations/${operation.id}`) + .then(({ data }) => setOperation(data)) + .catch(() => undefined); + }, 1000); + return () => window.clearTimeout(timer); + }, [operation]); + + useEffect(() => { + if (!operation || !terminal(operation.status)) return; + const key = `${operation.id}:${operation.status}`; + if (announced.current === key) return; + announced.current = key; + if (operation.status === "succeeded") { + void loadPlan(); + toast("Strategy snapshot completed.", "success"); + } else if (operation.status === "failed") toast("Strategy snapshot failed. You can retry safely.", "error"); + else toast("Strategy snapshot cancelled.", "info"); + }, [loadPlan, operation, toast]); + + const generate = async () => { + setLoading(true); + try { + const { data } = await api.post(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: null }); + announced.current = null; + setOperation(data.operation); + toast(data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info"); + } catch (error) { + toast(getApiErrorMessage(error, "Could not queue the strategy snapshot."), "error"); + } finally { + setLoading(false); + } + }; + + const mutateOperation = async (action: "cancel" | "retry") => { + if (!operation) return; + try { + announced.current = null; + const { data } = await api.post(`/operations/${operation.id}/${action}`); + setOperation(data); + } catch (error) { + toast(getApiErrorMessage(error, `Could not ${action} the strategy snapshot.`), "error"); + } + }; + + const working = !!operation && !terminal(operation.status); + return ( + + + + Strategy snapshot + An on-demand plan grounded in this advert and your saved career data. + + + + {operation && operation.status !== "succeeded" ? ( + + {operation.canCancel ? : null} + {operation.canRetry ? : null} + }> + {operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""} + + ) : null} + {loading && !plan ? : plan ? ( + + + + + + + ) : No strategy snapshot yet. Generate one when you want AI-assisted planning.} + + ); +} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 552c968..062f188 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -50,7 +50,7 @@ import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; import { JobApplication } from "../types"; import { useViewResource } from "../hooks/useViewResource"; -import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals"; +import { getWorkflowAction } from "../jobWorkflowSignals"; interface PagedResult { items: T[]; @@ -276,6 +276,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col setCompanyFilterId(nextCompany); setLocationFilter(view.location ?? ""); setNeedsFollowUpOnly(Boolean(view.needsFollowUp)); + setReadinessFilter(view.readiness ?? "all"); setPage(0); updateListRoute({ q: view.q || null, @@ -283,6 +284,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col companyId: nextCompany === "All" ? null : String(nextCompany), location: view.location || null, needsFollowUp: view.needsFollowUp ? "1" : null, + readiness: view.readiness ?? null, page: null, }); }; @@ -303,7 +305,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col sortBy, sortDir, needsFollowUp: needsFollowUpOnly ? true : undefined, - }), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly]); + readiness: readinessFilter === "all" ? undefined : readinessFilter, + }), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly, readinessFilter]); const jobsResource = useViewResource( async () => { @@ -333,11 +336,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col updateListRoute({ sortBy: key === "dateApplied" ? null : key, sortDir: key === "dateApplied" && nextDirection === "desc" ? null : nextDirection, page: null }); }; - const filteredJobs = useMemo(() => { - if (readinessFilter === "all") return jobs; - if (readinessFilter === "interview") return jobs.filter((job) => needsInterviewPrep(job)); - return jobs.filter((job) => needsWorkflowWork(job)); - }, [jobs, readinessFilter]); + const filteredJobs = jobs; useEffect(() => { const focusJobId = (location.state as { focusJobId?: unknown } | null)?.focusJobId; @@ -532,7 +531,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - + + } {t("settingsNotificationsDelivery")} diff --git a/job-tracker-ui/src/confirm.tsx b/job-tracker-ui/src/confirm.tsx index 28476ff..afceb8d 100644 --- a/job-tracker-ui/src/confirm.tsx +++ b/job-tracker-ui/src/confirm.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; type ConfirmOptions = { @@ -15,12 +15,12 @@ type ConfirmContextValue = { type ConfirmState = ConfirmOptions & { open: boolean; - resolver?: (value: boolean) => void; }; const ConfirmContext = createContext(null); export function ConfirmProvider({ children }: { children: React.ReactNode }) { + const resolverRef = useRef<((value: boolean) => void) | null>(null); const [state, setState] = useState({ open: false, message: "", @@ -31,14 +31,15 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) { }); const closeWith = useCallback((value: boolean) => { - setState((prev) => { - prev.resolver?.(value); - return { ...prev, open: false, resolver: undefined }; - }); + const resolve = resolverRef.current; + resolverRef.current = null; + setState((prev) => ({ ...prev, open: false })); + resolve?.(value); }, []); const confirm = useCallback((options: ConfirmOptions) => { return new Promise((resolve) => { + resolverRef.current = resolve; setState({ open: true, title: options.title ?? "Confirm action", @@ -46,7 +47,6 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) { confirmLabel: options.confirmLabel ?? "Confirm", cancelLabel: options.cancelLabel ?? "Cancel", destructive: options.destructive ?? false, - resolver: resolve, }); }); }, []); diff --git a/job-tracker-ui/src/hooks/useCompanies.ts b/job-tracker-ui/src/hooks/useCompanies.ts index 4157c0d..bcab6cc 100644 --- a/job-tracker-ui/src/hooks/useCompanies.ts +++ b/job-tracker-ui/src/hooks/useCompanies.ts @@ -1,31 +1,50 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from "../auth"; import { Company } from "../types"; import { useViewResource, ViewResourceError } from "./useViewResource"; let cachedCompanies: Company[] | null = null; let inflight: Promise | null = null; +let cacheOwner = ""; + +function resetForOwner(owner: string) { + if (cacheOwner === owner) return; + cacheOwner = owner; + cachedCompanies = null; + inflight = null; +} + +function cachedForCurrentOwner() { + const owner = getAuthUserKey(); + resetForOwner(owner); + return cachedCompanies; +} async function fetchCompanies(): Promise { + const owner = getAuthUserKey(); + resetForOwner(owner); if (cachedCompanies) return cachedCompanies; if (inflight) return inflight; - inflight = api + const request = api .get('/companies') .then((r) => { - cachedCompanies = r.data; + if (cacheOwner === owner && getAuthUserKey() === owner) cachedCompanies = r.data; return r.data; }) .finally(() => { - inflight = null; + if (inflight === request) inflight = null; }); + inflight = request; return inflight; } export function invalidateCompaniesCache() { cachedCompanies = null; + inflight = null; } export function useCompanies(): { @@ -37,7 +56,7 @@ export function useCompanies(): { } { const [cacheBust, setCacheBust] = useState(0); const resource = useViewResource(fetchCompanies, { - initialData: cachedCompanies ?? [], + initialData: cachedForCurrentOwner() ?? [], errorMessage: 'Unable to load companies right now.', deps: [cacheBust], }); @@ -48,6 +67,15 @@ export function useCompanies(): { } }, [resource.data, resource.error]); + useEffect(() => { + const reloadForAccount = () => { + resetForOwner(getAuthUserKey()); + setCacheBust((value) => value + 1); + }; + window.addEventListener(AUTH_USER_CHANGED_EVENT, reloadForAccount); + return () => window.removeEventListener(AUTH_USER_CHANGED_EVENT, reloadForAccount); + }, []); + return { companies: resource.data, loading: resource.loading, diff --git a/job-tracker-ui/src/hooks/useViewResource.ts b/job-tracker-ui/src/hooks/useViewResource.ts index 720dd78..e51fecc 100644 --- a/job-tracker-ui/src/hooks/useViewResource.ts +++ b/job-tracker-ui/src/hooks/useViewResource.ts @@ -66,6 +66,7 @@ export function useViewResource( const [error, setError] = useState(null); const hasLoadedRef = useRef(hasLoaded); const loadRef = useRef(load); + const requestSequence = useRef(0); useEffect(() => { hasLoadedRef.current = hasLoaded; @@ -78,18 +79,22 @@ export function useViewResource( const reload = useCallback(async () => { if (!enabled) return; + const requestId = ++requestSequence.current; const alreadyLoaded = hasLoadedRef.current; setLoading(!alreadyLoaded); setRefreshing(alreadyLoaded); try { const next = await loadRef.current(); + if (requestId !== requestSequence.current) return; setData(next); setError(null); setHasLoaded(true); } catch (err: any) { + if (requestId !== requestSequence.current) return; setError(normalizeError(err, errorMessage)); setHasLoaded(true); } finally { + if (requestId !== requestSequence.current) return; setLoading(false); setRefreshing(false); } @@ -97,6 +102,7 @@ export function useViewResource( useEffect(() => { if (!enabled) { + requestSequence.current += 1; setLoading(false); return; } diff --git a/job-tracker-ui/src/settings-view.test.tsx b/job-tracker-ui/src/settings-view.test.tsx index 4cd9488..5294f7c 100644 --- a/job-tracker-ui/src/settings-view.test.tsx +++ b/job-tracker-ui/src/settings-view.test.tsx @@ -27,7 +27,7 @@ const mockedApi = api as jest.Mocked; function renderView() { return render( - + { }, } as any); } + if (url === '/notification-settings') return Promise.resolve({ data: { emailFollowUpRemindersEnabled: true } } as any); return Promise.resolve({ data: {} } as any); }); window.localStorage.clear(); @@ -110,11 +111,14 @@ test('settings view has no accent picker and uses one follow-up section, one not expect(screen.queryAllByText(/open reminders/i)).toHaveLength(0); fireEvent.click(screen.getByRole('tab', { name: /notifications/i })); - expect(screen.getByText(/notification settings/i)).toBeInTheDocument(); + expect(screen.getByText(/^notification settings$/i)).toBeInTheDocument(); // SMTP status now lives under Admin → System → Settings (the old "check system status" link was removed). expect(screen.getAllByText(/smtp delivery and test mail live under/i).length).toBe(1); expect(screen.getByLabelText(/email reminders for follow-ups/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/email alerts for ghosted jobs/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/email alerts for ghosted jobs/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/email reminders for follow-ups/i)); + fireEvent.click(screen.getByRole('button', { name: /save notification settings/i })); + expect(mockedApi.put).toHaveBeenCalledWith('/notification-settings', { emailFollowUpRemindersEnabled: false }); }); test('AI privacy settings are server-backed and external processing is local-only by default', async () => { diff --git a/job-tracker-ui/src/use-view-resource.test.tsx b/job-tracker-ui/src/use-view-resource.test.tsx new file mode 100644 index 0000000..15281f8 --- /dev/null +++ b/job-tracker-ui/src/use-view-resource.test.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { act, render, screen } from "@testing-library/react"; + +import { useViewResource } from "./hooks/useViewResource"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { resolve = next; }); + return { promise, resolve }; +} + +it("ignores an older request that completes after a newer dependency load", async () => { + const first = deferred(); + const second = deferred(); + const load = jest.fn((key: string) => key === "first" ? first.promise : second.promise); + + function Probe({ query }: { query: string }) { + const resource = useViewResource(() => load(query), { + initialData: "empty", + errorMessage: "Unable to load.", + deps: [query], + }); + return
{resource.data}
; + } + + const view = render(); + view.rerender(); + + await act(async () => { second.resolve("new result"); }); + expect(screen.getByText("new result")).toBeInTheDocument(); + + await act(async () => { first.resolve("stale result"); }); + expect(screen.getByText("new result")).toBeInTheDocument(); + expect(screen.queryByText("stale result")).not.toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 9099881..485a423 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -30,6 +30,7 @@ import { } from "../components/ApplicationAssets"; import { ApplicationInterviewPrep } from "../components/InterviewPrep"; import EditJobDialog from "../components/EditJobDialog"; +import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist"; import { useConfirm } from "../confirm"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection, @@ -189,10 +190,12 @@ export function ApplicationWorkspace({ setEditOpen(true)} /> + {section === "overview" && jobId > 0 && } {section === "overview" && } {section === "job-details" && setEditOpen(true)} />} {/* Deterministic answer first, then the AI panel below it — the page never generates on load. */} {section === "analysis" && jobId > 0 && } + {section === "analysis" && jobId > 0 && } {section === "match" && jobId > 0 && } {section === "timeline" && jobId > 0 && } {section === "interview" && jobId > 0 && } diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index 99cac6d..f3ee643 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -27,6 +27,7 @@ import { StructuredCvProfile, } from "../profileCv"; import { JobApplication } from "../types"; +import { getUserScopedStorageKey } from "../auth"; type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; @@ -273,7 +274,9 @@ export default function ProfilePage() { setLastName(r.data?.lastName ?? ""); setDisplayName(r.data?.displayName ?? ""); setProfileCvText(r.data?.profileCvText ?? ""); - setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); + const persistedHeadline = parseStructuredCvJson(r.data?.profileCvStructureJson).contact.headline; + const userKey = r.data?.id || r.data?.email || r.data?.userName || "anon"; + setHeadline(persistedHeadline ?? window.localStorage.getItem(getUserScopedStorageKey("profileHeadline", userKey)) ?? ""); if (r.data?.provider === "local") { const pending = await api.get("/auth/email-change"); setPendingEmail(pending.data?.pendingEmail ?? null); @@ -489,7 +492,8 @@ export default function ProfilePage() { // /profile saves identity only. The backend does partial updates, so omitting the // master-profile fields leaves them untouched (they are owned by /career). await api.put("/auth/profile", { userName, firstName, lastName, displayName }); - window.localStorage.setItem("profileHeadline", headline.trim()); + const userKey = me?.id || me?.email || me?.userName; + if (userKey) window.localStorage.setItem(getUserScopedStorageKey("profileHeadline", userKey), headline.trim()); await loadProfile(); toast(t("profileUpdated"), "success"); } catch (e: any) { diff --git a/job-tracker-ui/src/workflow-trust-signals.test.tsx b/job-tracker-ui/src/workflow-trust-signals.test.tsx index e7944bc..8ecdb17 100644 --- a/job-tracker-ui/src/workflow-trust-signals.test.tsx +++ b/job-tracker-ui/src/workflow-trust-signals.test.tsx @@ -70,10 +70,18 @@ function buildJob(overrides: Partial): JobApplication { } function setupApiMocks({ reminders, jobs }: { reminders?: JobApplication[]; jobs?: JobApplication[] }) { - mockedApi.get.mockImplementation((url: string) => { + mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/companies') return Promise.resolve({ data: [{ id: 1, name: 'Acme' }, { id: 2, name: 'Beta' }] } as any); if (url === '/jobapplications/reminders') return Promise.resolve({ data: reminders ?? [] } as any); - if (url === '/jobapplications') return Promise.resolve({ data: { items: jobs ?? [], total: jobs?.length ?? 0, page: 1, pageSize: 15 } } as any); + if (url === '/jobapplications') { + const readiness = config?.params?.readiness; + const filtered = (jobs ?? []).filter((job) => readiness === 'interview' + ? job.workflowSignal?.needsInterviewPrep + : readiness === 'needs-work' + ? job.workflowSignal?.hasPackageGap || job.workflowSignal?.needsInterviewPrep + : true); + return Promise.resolve({ data: { items: filtered, total: filtered.length, page: 1, pageSize: 15 } } as any); + } if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: reminders?.length ?? 0, active: reminders?.length ?? 0, deleted: 0, byStatus: {}, appliedLast30Days: reminders?.length ?? 0, averageDaysSinceApplied: 7 } } as any); if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: [], responseRateBySource: [], topCompanies: [], totalResponses: 1, totalActive: reminders?.length ?? 0 } } as any); if (url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any); @@ -270,5 +278,8 @@ test('job table readiness filter follows workflow signals instead of raw notes o fireEvent.click(await screen.findByRole('option', { name: /needs work/i })); expect(await screen.findByText(/application engineer/i)).toBeInTheDocument(); - expect(screen.queryByText(/operations analyst/i)).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(/operations analyst/i)).not.toBeInTheDocument()); + expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications', expect.objectContaining({ + params: expect.objectContaining({ readiness: 'needs-work' }), + })); }); diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index 1484992..1fb9977 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -1096,7 +1096,13 @@ def _extract_plain_text(data: bytes) -> str: async def extract_text(file: UploadFile = File(...)): filename = file.filename or "document" extension = "." + filename.rsplit(".", 1)[1].lower() if "." in filename else "" - data = await file.read() + data = bytearray() + while len(data) <= MAX_EXTRACT_FILE_BYTES: + chunk = await file.read(min(64 * 1024, MAX_EXTRACT_FILE_BYTES - len(data) + 1)) + if not chunk: + break + data.extend(chunk) + data = bytes(data) if not data: raise HTTPException(status_code=400, detail="The uploaded file was empty.") if len(data) > MAX_EXTRACT_FILE_BYTES: diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py index 2c0135e..ef33dec 100644 --- a/tools/summarizer/tests/test_app.py +++ b/tools/summarizer/tests/test_app.py @@ -510,6 +510,18 @@ def test_health_stays_open_so_probes_and_healthchecks_work(monkeypatch): assert client.get("/health").status_code == 200 +def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch): + module = load_app_module(monkeypatch) + monkeypatch.setattr(module, "MAX_EXTRACT_FILE_BYTES", 32) + monkeypatch.setattr(module, "_extract_plain_text", lambda data: (_ for _ in ()).throw(AssertionError("parser must not run"))) + client = TestClient(module.app) + + response = client.post("/extract-text", files={"file": ("large.txt", b"x" * 64, "text/plain")}) + + assert response.status_code == 400 + assert "too large" in response.json()["detail"].lower() + + def test_cache_purge_requires_service_token_and_clears_content(monkeypatch): module = load_app_module(monkeypatch, service_token="s3cret") module.cache["synthetic-key"] = "synthetic-summary"