diff --git a/JobTrackerApi.Tests/CorrespondenceControllerTests.cs b/JobTrackerApi.Tests/CorrespondenceControllerTests.cs index 1cfdfa8..bbbe3bc 100644 --- a/JobTrackerApi.Tests/CorrespondenceControllerTests.cs +++ b/JobTrackerApi.Tests/CorrespondenceControllerTests.cs @@ -85,4 +85,45 @@ public sealed class CorrespondenceControllerTests var otherResult = await new CorrespondenceController(otherDb).GetMessage(messageId, CancellationToken.None); Assert.IsType(otherResult.Result); } + + [Fact] + public async Task Paged_inbox_returns_every_owned_message_without_the_legacy_cap() + { + await using var db = TestHostFactory.CreateInMemoryDb(); + var company = new Company { Name = "Scale fixture", OwnerUserId = "user-1" }; + var otherCompany = new Company { Name = "Other tenant", OwnerUserId = "user-2" }; + db.Companies.AddRange(company, otherCompany); + await db.SaveChangesAsync(); + var job = new JobApplication { JobTitle = "Paged role", CompanyId = company.Id, OwnerUserId = "user-1" }; + var otherJob = new JobApplication { JobTitle = "Private role", CompanyId = otherCompany.Id, OwnerUserId = "user-2" }; + db.JobApplications.AddRange(job, otherJob); + await db.SaveChangesAsync(); + db.Correspondences.AddRange(Enumerable.Range(1, 205).Select(index => new Correspondence + { + JobApplicationId = job.Id, + From = "Recruiter", + Subject = $"Message {index}", + Content = $"Content {index}", + Date = new DateTime(2026, 1, 1).AddMinutes(index) + })); + db.Correspondences.Add(new Correspondence + { + JobApplicationId = otherJob.Id, + From = "Other recruiter", + Subject = "Private message", + Content = "Must stay tenant isolated", + Date = new DateTime(2026, 2, 1) + }); + await db.SaveChangesAsync(); + + var result = await new CorrespondenceController(db).GetInboxPage(null, null, null, 3, 100, CancellationToken.None); + var page = Assert.IsType(Assert.IsType(result.Result).Value); + + Assert.Equal(205, page.Total); + Assert.Equal(3, page.TotalPages); + Assert.Equal(3, page.Page); + Assert.Equal(5, page.Items.Count); + Assert.Equal("Message 5", page.Items[0].Subject); + Assert.Equal("Message 1", page.Items[^1].Subject); + } } diff --git a/JobTrackerApi.Tests/UsersControllerTests.cs b/JobTrackerApi.Tests/UsersControllerTests.cs index 95ced4d..cf0a51a 100644 --- a/JobTrackerApi.Tests/UsersControllerTests.cs +++ b/JobTrackerApi.Tests/UsersControllerTests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Linq.Expressions; +using System.Data.Common; using JobTrackerApi.Controllers; using JobTrackerApi.Models; using JobTrackerApi.Services; @@ -11,6 +12,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Data.Sqlite; +using JobTrackerApi.Data; using Moq; using Xunit; @@ -102,6 +107,42 @@ public sealed class UsersControllerTests Assert.True(Assert.Single(rows, row => row.Id == member.Id).CanRemoveAdmin); } + [Fact] + public async Task List_uses_a_fixed_number_of_role_queries_for_many_users() + { + var counter = new CommandCounter(); + 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) + .AddInterceptors(counter) + .Options; + await using var db = new JobTrackerContext(options, currentUser.Object); + await db.Database.EnsureCreatedAsync(); + var adminRole = new IdentityRole("Admin") { Id = "role-admin", NormalizedName = "ADMIN" }; + var memberRole = new IdentityRole("Member") { Id = "role-member", NormalizedName = "MEMBER" }; + db.Roles.AddRange(adminRole, memberRole); + var usersToAdd = Enumerable.Range(1, 75).Select(index => User(index == 1 ? "admin-1" : $"user-{index}")).ToList(); + db.Users.AddRange(usersToAdd); + db.UserRoles.Add(new IdentityUserRole { UserId = "admin-1", RoleId = adminRole.Id }); + foreach (var user in usersToAdd.Skip(1)) + db.UserRoles.Add(new IdentityUserRole { UserId = user.Id, RoleId = memberRole.Id }); + await db.SaveChangesAsync(); + counter.Reset(); + + var manager = TestHostFactory.CreateUserManager(); + var controller = CreateController(manager, "admin-1", db); + var action = await controller.List(CancellationToken.None); + + var rows = Assert.IsType>(Assert.IsType(action.Result).Value); + Assert.Equal(75, rows.Count); + Assert.Equal(2, counter.ReaderCount); + manager.Verify(userManager => userManager.GetRolesAsync(It.IsAny()), Times.Never); + manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny()), Times.Never); + } + private static ApplicationUser User(string id) => new() { Id = id, @@ -109,7 +150,7 @@ public sealed class UsersControllerTests UserName = $"{id}@example.com" }; - private static UsersController CreateController(Mock> users, string currentUserId) + private static UsersController CreateController(Mock> users, string currentUserId, JobTrackerContext? db = null) { var roleStore = new Mock>(); var roles = new Mock>( @@ -125,7 +166,8 @@ public sealed class UsersControllerTests Mock.Of(), new ConfigurationBuilder().Build(), new NullLogger(), - ExternalOrigin.Parse("http://localhost:3000", production: false)); + ExternalOrigin.Parse("http://localhost:3000", production: false), + db: db); var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, currentUserId)], "test"); controller.ControllerContext = new ControllerContext @@ -135,6 +177,23 @@ public sealed class UsersControllerTests return controller; } + private sealed class CommandCounter : DbCommandInterceptor + { + public int ReaderCount { get; private set; } + + public void Reset() => ReaderCount = 0; + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + ReaderCount++; + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + } + private sealed class TestAsyncQueryProvider(IQueryProvider inner) : IAsyncQueryProvider { public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); diff --git a/JobTrackerApi/Controllers/CorrespondenceController.cs b/JobTrackerApi/Controllers/CorrespondenceController.cs index 69c8da0..56c64ea 100644 --- a/JobTrackerApi/Controllers/CorrespondenceController.cs +++ b/JobTrackerApi/Controllers/CorrespondenceController.cs @@ -51,14 +51,48 @@ namespace JobTrackerApi.Controllers int LabelCount, int AttachmentCount); + public sealed record CorrespondenceInboxPageDto( + List Items, + int Page, + int PageSize, + int Total, + int TotalPages); + [HttpGet] public async Task>> GetInbox( [FromQuery] string? q, [FromQuery] string? direction, [FromQuery] string? linkState, CancellationToken cancellationToken) + { + var query = BuildInboxQuery(q, direction, linkState); + var items = await LoadInboxItemsAsync(query, 0, 200, cancellationToken); + return Ok(items); + } + + [HttpGet("page")] + public async Task> GetInboxPage( + [FromQuery] string? q, + [FromQuery] string? direction, + [FromQuery] string? linkState, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + CancellationToken cancellationToken = default) + { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, 100); + var query = BuildInboxQuery(q, direction, linkState); + var total = await query.CountAsync(cancellationToken); + var totalPages = total == 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize); + if (totalPages > 0) page = Math.Min(page, totalPages); + var items = await LoadInboxItemsAsync(query, (page - 1) * pageSize, pageSize, cancellationToken); + return Ok(new CorrespondenceInboxPageDto(items, page, pageSize, total, totalPages)); + } + + private IQueryable BuildInboxQuery(string? q, string? direction, string? linkState) { var query = _db.Correspondences + .AsNoTracking() .Include(c => c.JobApplication) .ThenInclude(j => j.Company) .AsQueryable(); @@ -88,9 +122,20 @@ namespace JobTrackerApi.Controllers query = query.Where(c => c.ExternalThreadId == null); } + return query; + } + + private static async Task> LoadInboxItemsAsync( + IQueryable query, + int skip, + int take, + CancellationToken cancellationToken) + { var rows = await query .OrderByDescending(c => c.Date) - .Take(200) + .ThenByDescending(c => c.Id) + .Skip(skip) + .Take(take) .Select(c => new { c.Id, @@ -132,7 +177,7 @@ namespace JobTrackerApi.Controllers DeserializeLabels(c.ExternalLabelsJson).Count, DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList(); - return Ok(items); + return items; } // GET all messages for a job diff --git a/JobTrackerApi/Controllers/UsersController.cs b/JobTrackerApi/Controllers/UsersController.cs index 7561752..0a30a3d 100644 --- a/JobTrackerApi/Controllers/UsersController.cs +++ b/JobTrackerApi/Controllers/UsersController.cs @@ -1,5 +1,6 @@ using JobTrackerApi.Models; using JobTrackerApi.Services; +using JobTrackerApi.Data; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -20,7 +21,8 @@ public sealed class UsersController : ControllerBase private readonly ExternalOrigin _externalOrigin; private readonly ILogger _logger; private readonly AccountDeletionService? _deletions; - public UsersController(UserManager users, RoleManager roles, IAppEmailSender email, IConfiguration cfg, ILogger logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null) + private readonly JobTrackerContext? _db; + public UsersController(UserManager users, RoleManager roles, IAppEmailSender email, IConfiguration cfg, ILogger logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null, JobTrackerContext? db = null) { _users = users; _roles = roles; @@ -28,6 +30,7 @@ public sealed class UsersController : ControllerBase _externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg); _logger = logger; _deletions = deletions; + _db = db; } public sealed record UserDto( @@ -47,6 +50,36 @@ public sealed class UsersController : ControllerBase [HttpGet] public async Task>> List(CancellationToken cancellationToken) { + if (_db is not null) + { + var users = await _db.Users.AsNoTracking() + .OrderBy(user => user.Email) + .ToListAsync(cancellationToken); + var roleRows = await ( + from userRole in _db.UserRoles.AsNoTracking() + join role in _db.Roles.AsNoTracking() on userRole.RoleId equals role.Id + select new { userRole.UserId, role.Name }) + .ToListAsync(cancellationToken); + var rolesByUser = roleRows + .Where(row => !string.IsNullOrWhiteSpace(row.Name)) + .GroupBy(row => row.UserId) + .ToDictionary(group => group.Key, group => group.Select(row => row.Name!).ToList()); + var relationalAdminCount = roleRows + .Where(row => string.Equals(row.Name, "Admin", StringComparison.OrdinalIgnoreCase)) + .Select(row => row.UserId) + .Distinct(StringComparer.Ordinal) + .Count(); + var relationalCurrentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + + return Ok(users.Select(user => + { + var roles = rolesByUser.GetValueOrDefault(user.Id) ?? []; + return ToDto(user, roles, relationalCurrentUserId, !roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) || relationalAdminCount > 1); + }).ToList()); + } + + // Retained only for isolated controller tests/manual construction. The application DI path + // always supplies JobTrackerContext and uses the fixed two-query projection above. var items = await _users.Users .OrderBy(u => u.Email) .ToListAsync(cancellationToken); diff --git a/docs/architecture/technical-debt.md b/docs/architecture/technical-debt.md index e7b28b1..f45ad54 100644 --- a/docs/architecture/technical-debt.md +++ b/docs/architecture/technical-debt.md @@ -35,6 +35,8 @@ operator/external dependencies belong in `BLOCKERS.md`. public artifact is a real PDF in the browser smoke suite. - Sandboxed authenticated CV preview iframes without enabling scripts, isolated public-PDF request budgets by client and slug, and removed/ignored the tracked expired acceptance JWT artifact. +- Replaced the admin user list's per-user role lookup with a fixed two-query relational projection, + and moved the Job email UI to an additive paged inbox endpoint while preserving the legacy route. - Removed unfinished Portfolio/Notes workspace navigation promises; existing project, attachment, and application-note surfaces remain authoritative, and stale section links fall back to Overview. diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index 5974087..02f558a 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -219,3 +219,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-185 | Stripe gateway seam, mocked checkout/webhook lifecycle, entitlement tests and full backend | Repository root | Prove checkout identity and downgrade safety without using external Stripe | PASS — entitlement/billing 33/33 and backend 677/677; configured `price_` and stable user metadata reach Checkout, active grants Pro, `past_due` revokes it, canceled replay remains revoked without duplicate role mutation, non-AI profile data survives, and `prod_` in the price setting fails closed | In-process fake only; no Stripe network, customer, secret mutation, MariaDB or production call | Local POL-001 Stripe lifecycle gap closed; configured Stripe account journey remains blocked | | V-186 | Migration-chain regression tests, EF model parity/scripts, direct EF SQLite, real application startup and disposable MariaDB 11.8 fresh/restart | Repository root / disposable local databases | Close the historical blank-chain defect without changing applied production state or losing populated rows | PASS — migration tests 3/3 and backend 680/680; blank SQLite reaches all 29 migrations twice, an older populated checkpoint preserves job title/date/owner/summary, EF-only SQLite subsequently serves `/health`, and fresh/restarted MariaDB serves `/health` with 29 migrations, 49 tables and provider-correct sampled ID/owner/decimal/timestamp types. MariaDB script constrains identifiers to 64 characters | Synthetic disposable databases only; no production migration, downgrade, backup restore or private row. Migration/reconciler dual ownership remains JT-019 architectural debt | Blank-chain blocker closed; production restore/rollout remains gated | | V-187 | Focused public-CV/rate-key tests, focused authenticated-preview Jest, full backend, production frontend build and tracked-tree secret-pattern scan | Repository root / `job-tracker-ui` | Close the remaining low-risk public PDF, preview sandbox and tracked JWT hardening findings | PASS — backend focused 4/4 and full 681/681; preview Jest 15/15; production build passes. Two clients receive independent same-slug PDF partitions, both authenticated preview iframes disable scripts with a sandbox, and the values-suppressed tracked-tree scan finds no JWT/private-key pattern outside audit/operations records | No stress test, production request, history rewrite or Data Protection inspection. A coordinated history rewrite remains outside this change | JT-023/JT-025 current-tree gaps closed; expired JT-020 artifact removed from current tree | +| V-188 | 75-user query-count fixture, 205-message/two-tenant pagination fixture, focused/full backend and frontend, optimized build | Repository root / `job-tracker-ui` | Remove JT-021's confirmed N+1 and silent 200-message ceiling without breaking old clients | PASS — admin list performs two reads independent of 75 users; page 3 returns the final 5 of 205 owned messages and excludes another tenant. Focused backend 9/9, correspondence UI 15/15, backend 683/683, frontend 58 suites/239 tests and build pass. UI page navigation and filter reset are covered; the legacy inbox endpoint remains unchanged | Synthetic SQLite/InMemory/JSDOM only; no provider mailbox, production dataset or p95 load test. Current page linked/inbound chips intentionally describe the visible page | JT-021 repository defects closed; provider/production capacity remains operational evidence | diff --git a/docs/verification/mail-001-job-email-hub.md b/docs/verification/mail-001-job-email-hub.md index bba55d6..0a25e82 100644 --- a/docs/verification/mail-001-job-email-hub.md +++ b/docs/verification/mail-001-job-email-hub.md @@ -191,6 +191,18 @@ Status: `IN PROGRESS`. Canonical hub routing, provider-neutral reads, explicit-c - Preserve minimal audit metadata without sensitive body logging. Free non-AI access is verified; future AI assistance remains a Pro/privacy-gated addition, not a prerequisite for basic email. - Complete remaining link/unlink/dismiss/draft/send/failure/two-user browser/production provider gates. Shared application-context behavior is now covered locally. No real email may be sent during repository verification. +## Implemented complete inbox pagination + +- The current Job email UI uses an additive `/api/correspondence/page` contract with bounded page + sizes, total/page metadata and deterministic date/ID ordering. The original endpoint remains for + compatibility rather than changing an existing client contract in place. +- A 205-message tenant fixture proves the third page returns the final five owned messages and + excludes another tenant. The UI exposes accessible page navigation, resets to page one when a + filter changes and reports the complete filtered total rather than the current page count. +- Focused backend/admin tests pass 9/9, correspondence UI 15/15, full backend 683/683, full frontend + 58 suites/239 tests and the optimized production build passes. Provider and production mailbox + performance remain unmeasured; no provider or email was contacted. + ## Validation limitation The first focused Jest invocation exhibited the repository's open-handle delay. The passing focused and full runs used `--forceExit`; the full run took 228.709 seconds. A Next build process also failed to exit after compilation; only the exact PIDs started by those build attempts were stopped, then a clean build completed. This is recorded as tooling/runtime behavior, not hidden. diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 6e99751..b961c7d 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -10,9 +10,9 @@ Updated: 2026-08-15 - **Production-verified work:** None. - **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require network/backup/model/deployment authority and unfinished dependencies. Real provider and live deletion/restore checks remain gated; DEP-001 awaits approved merge/live verification. - **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages. -- **Immediate order:** the eight-item immediate queue is complete locally: admin version (`a6cffe0`), Career persistence (`f0b9b22`), CV contrast (`3b86ea2`), JOBS-002 (`deed948`), accessibility (`a7c2549`), PRODUCT-001 (`a25c31b`), VER-001 and tracking reconciliation. SEC-009 is complete at `842e793`; PROD-001 read-only evidence and the PROD-003 plan-only harness are complete pending this documentation commit. No further independent implementation remains. +- **Immediate order:** all sixteen immediate repository items are complete locally, including the original UI/release queue plus SEC-009 cache/tombstone safety, worker restart clocks, universal AI accounting, email-token/Stripe lifecycle tests, exhaustive Job email selectors, the repaired migration chain, CV/public-edge hardening and measured admin/mail scaling. PROD-001 read-only evidence and the PROD-003 plan-only harness are also complete. The final audit is checking tooling/documentation before declaring only external blockers remain. - **Status counts:** 8 `VERIFIED LOCALLY`; 25 `IMPLEMENTED — NOT VERIFIED`; 0 `IN PROGRESS`; 1 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. -- **Test status:** backend 680/680; frontend 58/58 suites and 237/237 tests; AI sidecar 23/23; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit 0 evidence remains current because the lockfile did not change. Jest force-exit/open-handle behavior remains recorded. +- **Test status:** backend 683/683; frontend 58/58 suites and 239/239 tests; AI sidecar 23/23; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit 0 evidence remains current because the lockfile did not change. Jest's slow/open-handle behavior remains recorded. - **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default. - **Production status:** State unchanged. Sanitized read-only SSH inventory was performed; no logs, prompts, private rows/content or secret values were read, and no provider/model call, model pull, service restart, file/config change, backup, restore, migration or deployment occurred. It confirmed all-interface Ollama/frontend listeners and stale database-only backups as rollout blockers. - **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. The direct clean EF-only SQLite defect and synchronous AI accounting gap are closed; migration/reconciler dual ownership remains architectural debt. diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index 1113bbd..1411333 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -69,6 +69,7 @@ This queue records the highest-value work that can proceed without production cr | 13 | Remove the Job email 100-application selector ceiling | MAIL-001 | Locally complete through bounded owner-filtered server search and tenant/UI regressions. | | 14 | Repair the full historical migration chain | CORE-001, JT-019 | Locally complete for blank/idempotent/populated SQLite, EF-only-to-application startup, provider scripts and disposable MariaDB 11.8 fresh/restart. Production restore/rollout remains blocked. | | 15 | Close low-risk CV/public-edge hardening | JT-020, JT-023, JT-025 | Locally complete. Authenticated previews are sandboxed without scripts, public PDF budgets are client-and-slug scoped, and the expired tracked JWT fixture is removed and ignored. Git history remediation remains a separate coordinated decision. | +| 16 | Remove measured admin/mail scaling defects | JT-021, MAIL-001 | Locally complete. Admin roles use two reads independent of user count; Job email has bounded accessible pagination with totals and deterministic ordering while the old endpoint remains compatible. Provider/production capacity measurement remains external. | ## Requirement coverage index @@ -673,7 +674,7 @@ This queue records the highest-value work that can proceed without production cr - **Required production verification:** provider read/draft/send requires explicit authorized synthetic account; never real unsolicited email. - **Status:** `IMPLEMENTED — NOT VERIFIED`. - **Blocker:** real provider/re-consent, full SEC-009 deletion, MariaDB, production and required 375/768/1440/theme/keyboard browser gates are unavailable or require new authority. -- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126–V-153. Draft/new-message UI 13/13, API/idempotency/rotation 10/10, persistence 1/1 with dual-provider reversible SQL and readable export 4/4; Free send policy 7/7; provider states 9/9; hub unlink 8/8 UI and 2/2 API; shared application context focused 10/10; prior send export/cascade focused 16/16; recovery/send focused 10/10; legacy follow-up/worker 10/10; delivery/capability 18/18; provider/correspondence 5/5; hub detail 5/5; backend 630/630; frontend 50/50 suites and 198/198 tests plus build/audit; local empty/disconnected and compatibility-route browser smoke at 1280×720. +- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126–V-153/V-183/V-188. Draft/new-message UI 13/13, API/idempotency/rotation 10/10, persistence 1/1 with dual-provider reversible SQL and readable export 4/4; Free send policy 7/7; provider states 9/9; hub unlink 8/8 UI and 2/2 API; shared application context focused 10/10; paged inbox/admin focused 9/9 and UI 15/15; backend 683/683; frontend 58 suites/239 tests plus build; local empty/disconnected and compatibility-route browser smoke at 1280×720. - **Commit:** `6008b4a` (hub), `536d403` (neutral reads), `a20775c` (safe detail), `653f011` (ledger), `e9937ac` (Gmail/Graph delivery adapters and consent), `123fc55` (explicit-confirmed send API), `449faeb` (confirmed reply composer), `ee5ef7e` (interrupted-send recovery), `8fe3903` (legacy SMTP retirement), `aff34cc` (content-free export and cascade evidence), `ff547df` (shared application context), `1dabbeb` (confirmed hub unlink), `f9e641c` (honest provider states), `7f41cb2` (Free email policy regression), `14b396a` (inert tenant draft persistence), `2fa4e38` (owner-isolated readable draft export), `a9bb22e` (tenant-safe revisioned draft API), `80b5532` (persisted draft send identity), `d3d2b67` (saved reply recovery/conflicts), `29de263` (definitive-failure identity rotation), `b735963` (new-message job/provider drafting). - **Remaining work:** SEC-009 repository deletion coverage is complete but production activation remains gated; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; browser/production verification remains. Server-side owner-filtered job choice search now reaches applications beyond the old first-page ceiling (V-183). Existing connections need explicit re-consent; IMAP remains read-only. No real email; uncertain sends need manual reconciliation. The clean full-chain SQLite rehearsal now passes (V-186). diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 3cfe329..1cb94ff 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -44,7 +44,7 @@ describe('CorrespondenceInboxPage', () => { { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false }, { provider: 'microsoft', displayName: 'Outlook', connected: false, address: null, canRead: false, canSend: false }, ] } as any); - if (url === '/correspondence') return Promise.resolve({ data: [ + if (url === '/correspondence/page') return Promise.resolve({ data: { items: [ { id: 1, jobApplicationId: 42, @@ -64,7 +64,7 @@ describe('CorrespondenceInboxPage', () => { labelCount: 2, attachmentCount: 1, }, - ] } as any); + ], page: 1, pageSize: 50, total: 1, totalPages: 1 } } as any); if (url === '/email/drafts') return Promise.resolve({ data: [] } as any); if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any); if (url === '/email/message') return Promise.resolve({ data: { @@ -101,10 +101,12 @@ describe('CorrespondenceInboxPage', () => { fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]); await waitFor(() => { - expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({ + expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', expect.objectContaining({ params: expect.objectContaining({ q: 'Maria', direction: 'inbound', + page: 1, + pageSize: 50, }), })); }); @@ -137,6 +139,44 @@ describe('CorrespondenceInboxPage', () => { }); }); + test('navigates beyond the first correspondence page', async () => { + const original = mockedApi.get.getMockImplementation(); + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/correspondence/page') { + const page = config?.params?.page ?? 1; + return Promise.resolve({ data: { + items: [{ + id: page, + jobApplicationId: 42, + companyName: page === 2 ? 'Second page company' : 'First page company', + jobTitle: 'Engineer', + from: 'Recruiter', + direction: 'inbound', + subject: `Page ${page}`, + channel: 'Email', + date: new Date().toISOString(), + contentPreview: `Page ${page} message`, + labelCount: 0, + attachmentCount: 0, + }], + page, + pageSize: 50, + total: 51, + totalPages: 2, + } } as any); + } + return original!(url, config); + }); + + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: /go to page 2/i })); + + expect(await screen.findByText(/second page company/i)).toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.objectContaining({ + params: expect.objectContaining({ page: 2, pageSize: 50 }), + })); + }); + test('unlinks a Gmail thread only after confirmation and returns it to review', async () => { mockedApi.post.mockResolvedValue({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } } as any); renderPage(); @@ -154,7 +194,7 @@ describe('CorrespondenceInboxPage', () => { note: 'Unlinked from Job email hub', nextDecision: 'review', })); - await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence', expect.anything())); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.anything())); expect(await screen.findByText(/returned to recruitment review/i)).toBeInTheDocument(); }); @@ -452,6 +492,6 @@ describe('CorrespondenceInboxPage', () => { ); expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument(); - expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence', expect.anything()); + expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/page', expect.anything()); }); }); diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index ac46633..75ac44d 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -9,6 +9,7 @@ import { FormControl, InputLabel, MenuItem, + Pagination, Paper, Select, Stack, @@ -42,6 +43,14 @@ export type CorrespondenceInboxItem = { attachmentCount: number; }; +type CorrespondenceInboxPage = { + items: CorrespondenceInboxItem[]; + page: number; + pageSize: number; + total: number; + totalPages: number; +}; + type EmailProviderStatus = { provider: string; displayName: string; @@ -116,6 +125,9 @@ export default function CorrespondenceInboxPage() { const { toast } = useToast(); const { confirm } = useConfirm(); const [items, setItems] = useState([]); + const [inboxPage, setInboxPage] = useState(1); + const [inboxTotal, setInboxTotal] = useState(0); + const [inboxTotalPages, setInboxTotalPages] = useState(0); const [providers, setProviders] = useState([]); const [providerStatusLoaded, setProviderStatusLoaded] = useState(false); const [jobs, setJobs] = useState([]); @@ -148,20 +160,27 @@ export default function CorrespondenceInboxPage() { const load = useCallback(async () => { setLoading(true); try { - const res = await api.get("/correspondence", { + const res = await api.get("/correspondence/page", { params: { q: query.trim() || undefined, direction: direction === "all" ? undefined : direction, linkState: linkState === "all" ? undefined : linkState, + page: inboxPage, + pageSize: 50, }, }); - setItems(res.data ?? []); + setItems(res.data?.items ?? []); + setInboxTotal(res.data?.total ?? 0); + setInboxTotalPages(res.data?.totalPages ?? 0); + if (res.data?.page && res.data.page !== inboxPage) setInboxPage(res.data.page); + setSelectedMessageId(null); + setMessageDetail(null); } catch (error) { toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error"); } finally { setLoading(false); } - }, [direction, linkState, query, toast]); + }, [direction, inboxPage, linkState, query, toast]); useEffect(() => { if (view === "inbox") void load(); @@ -524,7 +543,7 @@ export default function CorrespondenceInboxPage() { - {view === "inbox" ? } label={`${items.length} items`} variant="outlined" /> : null} + {view === "inbox" ? } label={`${inboxTotal} items`} variant="outlined" /> : null} {view === "inbox" ? 0 ? "success" : "default"} /> : null} {view === "inbox" ? : null} @@ -596,10 +615,10 @@ export default function CorrespondenceInboxPage() { {view === "review" ? : <> - setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" /> + { setQuery(e.target.value); setInboxPage(1); }} placeholder="Company, role, recruiter, subject" /> Direction - { setDirection(String(e.target.value)); setInboxPage(1); }}> All Inbound Outbound @@ -608,7 +627,7 @@ export default function CorrespondenceInboxPage() { Link state - { setLinkState(String(e.target.value)); setInboxPage(1); }}> All Linked threads Manual/internal only @@ -711,6 +730,17 @@ export default function CorrespondenceInboxPage() { ))} + {inboxTotalPages > 1 ? ( + + setInboxPage(value)} + color="primary" + aria-label="Correspondence pages" + /> + + ) : null} } );