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);
|
var otherResult = await new CorrespondenceController(otherDb).GetMessage(messageId, CancellationToken.None);
|
||||||
Assert.IsType<NotFoundResult>(otherResult.Result);
|
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.Security.Claims;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
using System.Data.Common;
|
||||||
using JobTrackerApi.Controllers;
|
using JobTrackerApi.Controllers;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
@@ -11,6 +12,10 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.EntityFrameworkCore.Query;
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -102,6 +107,42 @@ public sealed class UsersControllerTests
|
|||||||
Assert.True(Assert.Single(rows, row => row.Id == member.Id).CanRemoveAdmin);
|
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()
|
private static ApplicationUser User(string id) => new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
@@ -109,7 +150,7 @@ public sealed class UsersControllerTests
|
|||||||
UserName = $"{id}@example.com"
|
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 roleStore = new Mock<IRoleStore<IdentityRole>>();
|
||||||
var roles = new Mock<RoleManager<IdentityRole>>(
|
var roles = new Mock<RoleManager<IdentityRole>>(
|
||||||
@@ -125,7 +166,8 @@ public sealed class UsersControllerTests
|
|||||||
Mock.Of<IAppEmailSender>(),
|
Mock.Of<IAppEmailSender>(),
|
||||||
new ConfigurationBuilder().Build(),
|
new ConfigurationBuilder().Build(),
|
||||||
new NullLogger<UsersController>(),
|
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");
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, currentUserId)], "test");
|
||||||
controller.ControllerContext = new ControllerContext
|
controller.ControllerContext = new ControllerContext
|
||||||
@@ -135,6 +177,23 @@ public sealed class UsersControllerTests
|
|||||||
return controller;
|
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
|
private sealed class TestAsyncQueryProvider<TEntity>(IQueryProvider inner) : IAsyncQueryProvider
|
||||||
{
|
{
|
||||||
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
|
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
|
||||||
|
|||||||
@@ -51,14 +51,48 @@ namespace JobTrackerApi.Controllers
|
|||||||
int LabelCount,
|
int LabelCount,
|
||||||
int AttachmentCount);
|
int AttachmentCount);
|
||||||
|
|
||||||
|
public sealed record CorrespondenceInboxPageDto(
|
||||||
|
List<CorrespondenceInboxItemDto> Items,
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
int Total,
|
||||||
|
int TotalPages);
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
|
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
|
||||||
[FromQuery] string? q,
|
[FromQuery] string? q,
|
||||||
[FromQuery] string? direction,
|
[FromQuery] string? direction,
|
||||||
[FromQuery] string? linkState,
|
[FromQuery] string? linkState,
|
||||||
CancellationToken cancellationToken)
|
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
|
var query = _db.Correspondences
|
||||||
|
.AsNoTracking()
|
||||||
.Include(c => c.JobApplication)
|
.Include(c => c.JobApplication)
|
||||||
.ThenInclude(j => j.Company)
|
.ThenInclude(j => j.Company)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
@@ -88,9 +122,20 @@ namespace JobTrackerApi.Controllers
|
|||||||
query = query.Where(c => c.ExternalThreadId == null);
|
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
|
var rows = await query
|
||||||
.OrderByDescending(c => c.Date)
|
.OrderByDescending(c => c.Date)
|
||||||
.Take(200)
|
.ThenByDescending(c => c.Id)
|
||||||
|
.Skip(skip)
|
||||||
|
.Take(take)
|
||||||
.Select(c => new
|
.Select(c => new
|
||||||
{
|
{
|
||||||
c.Id,
|
c.Id,
|
||||||
@@ -132,7 +177,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
DeserializeLabels(c.ExternalLabelsJson).Count,
|
DeserializeLabels(c.ExternalLabelsJson).Count,
|
||||||
DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList();
|
DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList();
|
||||||
|
|
||||||
return Ok(items);
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET all messages for a job
|
// GET all messages for a job
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -20,7 +21,8 @@ public sealed class UsersController : ControllerBase
|
|||||||
private readonly ExternalOrigin _externalOrigin;
|
private readonly ExternalOrigin _externalOrigin;
|
||||||
private readonly ILogger<UsersController> _logger;
|
private readonly ILogger<UsersController> _logger;
|
||||||
private readonly AccountDeletionService? _deletions;
|
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;
|
_users = users;
|
||||||
_roles = roles;
|
_roles = roles;
|
||||||
@@ -28,6 +30,7 @@ public sealed class UsersController : ControllerBase
|
|||||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_deletions = deletions;
|
_deletions = deletions;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UserDto(
|
public sealed record UserDto(
|
||||||
@@ -47,6 +50,36 @@ public sealed class UsersController : ControllerBase
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
|
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
|
var items = await _users.Users
|
||||||
.OrderBy(u => u.Email)
|
.OrderBy(u => u.Email)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ operator/external dependencies belong in `BLOCKERS.md`.
|
|||||||
public artifact is a real PDF in the browser smoke suite.
|
public artifact is a real PDF in the browser smoke suite.
|
||||||
- Sandboxed authenticated CV preview iframes without enabling scripts, isolated public-PDF request
|
- 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.
|
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,
|
- Removed unfinished Portfolio/Notes workspace navigation promises; existing project, attachment,
|
||||||
and application-note surfaces remain authoritative, and stale section links fall back to Overview.
|
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-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-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-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.
|
- 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.
|
- 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
|
## 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.
|
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.
|
- **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.
|
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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.
|
- **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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 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.
|
- **Required production verification:** provider read/draft/send requires explicit authorized synthetic account; never real unsolicited email.
|
||||||
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
|
- **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.
|
- **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).
|
- **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).
|
- **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: '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 },
|
{ provider: 'microsoft', displayName: 'Outlook', connected: false, address: null, canRead: false, canSend: false },
|
||||||
] } as any);
|
] } as any);
|
||||||
if (url === '/correspondence') return Promise.resolve({ data: [
|
if (url === '/correspondence/page') return Promise.resolve({ data: { items: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
jobApplicationId: 42,
|
jobApplicationId: 42,
|
||||||
@@ -64,7 +64,7 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
labelCount: 2,
|
labelCount: 2,
|
||||||
attachmentCount: 1,
|
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 === '/email/drafts') return Promise.resolve({ data: [] } as any);
|
||||||
if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any);
|
if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any);
|
||||||
if (url === '/email/message') return Promise.resolve({ data: {
|
if (url === '/email/message') return Promise.resolve({ data: {
|
||||||
@@ -101,10 +101,12 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
|
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', expect.objectContaining({
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
q: 'Maria',
|
q: 'Maria',
|
||||||
direction: 'inbound',
|
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 () => {
|
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);
|
mockedApi.post.mockResolvedValue({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } } as any);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -154,7 +194,7 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
note: 'Unlinked from Job email hub',
|
note: 'Unlinked from Job email hub',
|
||||||
nextDecision: 'review',
|
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();
|
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(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,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -42,6 +43,14 @@ export type CorrespondenceInboxItem = {
|
|||||||
attachmentCount: number;
|
attachmentCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CorrespondenceInboxPage = {
|
||||||
|
items: CorrespondenceInboxItem[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
type EmailProviderStatus = {
|
type EmailProviderStatus = {
|
||||||
provider: string;
|
provider: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -116,6 +125,9 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
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 [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||||
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
||||||
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||||
@@ -148,20 +160,27 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
const res = await api.get<CorrespondenceInboxPage>("/correspondence/page", {
|
||||||
params: {
|
params: {
|
||||||
q: query.trim() || undefined,
|
q: query.trim() || undefined,
|
||||||
direction: direction === "all" ? undefined : direction,
|
direction: direction === "all" ? undefined : direction,
|
||||||
linkState: linkState === "all" ? undefined : linkState,
|
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) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [direction, linkState, query, toast]);
|
}, [direction, inboxPage, linkState, query, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "inbox") void load();
|
if (view === "inbox") void load();
|
||||||
@@ -524,7 +543,7 @@ export default function CorrespondenceInboxPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
<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.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||||
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
|
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
|
||||||
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
|
<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 /> : <>
|
{view === "review" ? <GmailReviewPage embedded /> : <>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
<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>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Direction</InputLabel>
|
<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="all">All</MenuItem>
|
||||||
<MenuItem value="inbound">Inbound</MenuItem>
|
<MenuItem value="inbound">Inbound</MenuItem>
|
||||||
<MenuItem value="outbound">Outbound</MenuItem>
|
<MenuItem value="outbound">Outbound</MenuItem>
|
||||||
@@ -608,7 +627,7 @@ export default function CorrespondenceInboxPage() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Link state</InputLabel>
|
<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="all">All</MenuItem>
|
||||||
<MenuItem value="linked">Linked threads</MenuItem>
|
<MenuItem value="linked">Linked threads</MenuItem>
|
||||||
<MenuItem value="manual">Manual/internal only</MenuItem>
|
<MenuItem value="manual">Manual/internal only</MenuItem>
|
||||||
@@ -711,6 +730,17 @@ export default function CorrespondenceInboxPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</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>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user