fix(scale): page mail and batch roles
This commit is contained in:
@@ -85,4 +85,45 @@ public sealed class CorrespondenceControllerTests
|
||||
var otherResult = await new CorrespondenceController(otherDb).GetMessage(messageId, CancellationToken.None);
|
||||
Assert.IsType<NotFoundResult>(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<CorrespondenceController.CorrespondenceInboxPageDto>(Assert.IsType<OkObjectResult>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("admin-1");
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.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<string> { UserId = "admin-1", RoleId = adminRole.Id });
|
||||
foreach (var user in usersToAdd.Skip(1))
|
||||
db.UserRoles.Add(new IdentityUserRole<string> { 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<List<UsersController.UserDto>>(Assert.IsType<OkObjectResult>(action.Result).Value);
|
||||
Assert.Equal(75, rows.Count);
|
||||
Assert.Equal(2, counter.ReaderCount);
|
||||
manager.Verify(userManager => userManager.GetRolesAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny<string>()), 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<UserManager<ApplicationUser>> users, string currentUserId)
|
||||
private static UsersController CreateController(Mock<UserManager<ApplicationUser>> users, string currentUserId, JobTrackerContext? db = null)
|
||||
{
|
||||
var roleStore = new Mock<IRoleStore<IdentityRole>>();
|
||||
var roles = new Mock<RoleManager<IdentityRole>>(
|
||||
@@ -125,7 +166,8 @@ public sealed class UsersControllerTests
|
||||
Mock.Of<IAppEmailSender>(),
|
||||
new ConfigurationBuilder().Build(),
|
||||
new NullLogger<UsersController>(),
|
||||
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<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<DbDataReader> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ReaderCount++;
|
||||
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAsyncQueryProvider<TEntity>(IQueryProvider inner) : IAsyncQueryProvider
|
||||
{
|
||||
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
|
||||
|
||||
@@ -51,14 +51,48 @@ namespace JobTrackerApi.Controllers
|
||||
int LabelCount,
|
||||
int AttachmentCount);
|
||||
|
||||
public sealed record CorrespondenceInboxPageDto(
|
||||
List<CorrespondenceInboxItemDto> Items,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int Total,
|
||||
int TotalPages);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> 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<ActionResult<CorrespondenceInboxPageDto>> 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<Correspondence> 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<List<CorrespondenceInboxItemDto>> LoadInboxItemsAsync(
|
||||
IQueryable<Correspondence> 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
|
||||
|
||||
@@ -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<UsersController> _logger;
|
||||
private readonly AccountDeletionService? _deletions;
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null)
|
||||
private readonly JobTrackerContext? _db;
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> 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<ActionResult<List<UserDto>>> 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CorrespondenceInboxItem[]>([]);
|
||||
const [inboxPage, setInboxPage] = useState(1);
|
||||
const [inboxTotal, setInboxTotal] = useState(0);
|
||||
const [inboxTotalPages, setInboxTotalPages] = useState(0);
|
||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
||||
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||
@@ -148,20 +160,27 @@ export default function CorrespondenceInboxPage() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
||||
const res = await api.get<CorrespondenceInboxPage>("/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() {
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" /> : null}
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${inboxTotal} items`} variant="outlined" /> : null}
|
||||
{view === "inbox" ? <Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
|
||||
@@ -596,10 +615,10 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
{view === "review" ? <GmailReviewPage embedded /> : <>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
||||
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
||||
<TextField label="Search" value={query} onChange={(e) => { setQuery(e.target.value); setInboxPage(1); }} placeholder="Company, role, recruiter, subject" />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Direction</InputLabel>
|
||||
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
|
||||
<Select value={direction} label="Direction" onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="inbound">Inbound</MenuItem>
|
||||
<MenuItem value="outbound">Outbound</MenuItem>
|
||||
@@ -608,7 +627,7 @@ export default function CorrespondenceInboxPage() {
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Link state</InputLabel>
|
||||
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
|
||||
<Select value={linkState} label="Link state" onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="linked">Linked threads</MenuItem>
|
||||
<MenuItem value="manual">Manual/internal only</MenuItem>
|
||||
@@ -711,6 +730,17 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
{inboxTotalPages > 1 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<Pagination
|
||||
page={inboxPage}
|
||||
count={inboxTotalPages}
|
||||
onChange={(_, value) => setInboxPage(value)}
|
||||
color="primary"
|
||||
aria-label="Correspondence pages"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user