fix(mail): search every owned job
Replace ineffective pageSize=100 selectors with a bounded owner-filtered search endpoint for composing and moving linked threads.
This commit is contained in:
@@ -74,6 +74,45 @@ public sealed class JobApplicationsAuthorizationTests
|
|||||||
Assert.IsType<NotFoundResult>(result.Result);
|
Assert.IsType<NotFoundResult>(result.Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Job_choices_searches_all_owned_rows_without_leaking_another_tenant()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
await using (var ownerDb = CreateDb(dbName, "owner-1"))
|
||||||
|
{
|
||||||
|
var company = new Company { Name = "Owner company", OwnerUserId = "owner-1" };
|
||||||
|
ownerDb.Companies.Add(company);
|
||||||
|
for (var index = 0; index < 130; index++)
|
||||||
|
{
|
||||||
|
ownerDb.JobApplications.Add(new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = index == 0 ? "Historic target role" : $"Recent role {index}",
|
||||||
|
Company = company,
|
||||||
|
OwnerUserId = "owner-1",
|
||||||
|
SavedAt = DateTime.UtcNow.AddDays(index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ownerDb.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
await using (var otherDb = CreateDb(dbName, "owner-2"))
|
||||||
|
{
|
||||||
|
var company = new Company { Name = "Other tenant", OwnerUserId = "owner-2" };
|
||||||
|
otherDb.JobApplications.Add(new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Historic target private", Company = company, OwnerUserId = "owner-2",
|
||||||
|
});
|
||||||
|
await otherDb.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var searchDb = CreateDb(dbName, "owner-1");
|
||||||
|
var result = await CreateController(searchDb).GetChoices("historic target", 20, default);
|
||||||
|
var choices = Assert.IsType<List<JobApplicationChoiceDto>>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
||||||
|
|
||||||
|
var choice = Assert.Single(choices);
|
||||||
|
Assert.Equal("Historic target role", choice.JobTitle);
|
||||||
|
Assert.Equal("Owner company", choice.CompanyName);
|
||||||
|
}
|
||||||
|
|
||||||
private static JobTrackerContext CreateDb(string dbName, string? userId)
|
private static JobTrackerContext CreateDb(string dbName, string? userId)
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ using static JobTrackerApi.Services.JobApplicationHelpers;
|
|||||||
|
|
||||||
namespace JobTrackerApi.Controllers
|
namespace JobTrackerApi.Controllers
|
||||||
{
|
{
|
||||||
|
public sealed record JobApplicationChoiceDto(int Id, string JobTitle, string CompanyName);
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
// Explicitly authorized. These endpoints are all tenant-scoped user data, so they must not
|
// Explicitly authorized. These endpoints are all tenant-scoped user data, so they must not
|
||||||
// depend on the Auth:Require fallback policy being switched on: a deployment that lost that flag
|
// depend on the Auth:Require fallback policy being switched on: a deployment that lost that flag
|
||||||
@@ -2268,6 +2270,31 @@ Job description:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("choices")]
|
||||||
|
public async Task<ActionResult<List<JobApplicationChoiceDto>>> GetChoices(
|
||||||
|
[FromQuery] string? q = null,
|
||||||
|
[FromQuery] int limit = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 50);
|
||||||
|
var query = _db.JobApplications.AsNoTracking()
|
||||||
|
.Where(item => !item.IsDeleted);
|
||||||
|
if (!string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
var like = $"%{q.Trim()}%";
|
||||||
|
query = query.Where(item =>
|
||||||
|
EF.Functions.Like(item.JobTitle, like) ||
|
||||||
|
EF.Functions.Like(item.Company.Name, like));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await query
|
||||||
|
.OrderByDescending(item => item.DateApplied ?? item.SavedAt)
|
||||||
|
.ThenByDescending(item => item.Id)
|
||||||
|
.Select(item => new JobApplicationChoiceDto(item.Id, item.JobTitle, item.Company.Name))
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("ai-metrics")]
|
[HttpGet("ai-metrics")]
|
||||||
[HttpGet("summarizer-metrics")]
|
[HttpGet("summarizer-metrics")]
|
||||||
public async Task<ActionResult<AiServiceMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
|
public async Task<ActionResult<AiServiceMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -214,3 +214,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
|
|||||||
| V-180 | CV operation/store focused real-SQLite tests and full backend | Repository root | Keep dormant CV extraction history consistent with cancellation, deadline recovery and retry before worker claim | PASS — focused 17/17 and backend 660/660. Cancel sets the run terminal immediately, retry reopens it, deadline recovery fails it, and owner/task/subject predicates prevent unrelated updates | Synthetic rows only; no parser/model/MariaDB/production process interruption | AI-004 dormant-row consistency gap closed |
|
| V-180 | CV operation/store focused real-SQLite tests and full backend | Repository root | Keep dormant CV extraction history consistent with cancellation, deadline recovery and retry before worker claim | PASS — focused 17/17 and backend 660/660. Cancel sets the run terminal immediately, retry reopens it, deadline recovery fails it, and owner/task/subject predicates prevent unrelated updates | Synthetic rows only; no parser/model/MariaDB/production process interruption | AI-004 dormant-row consistency gap closed |
|
||||||
| V-181 | AI usage meter/operation/workspace/export/deletion real-SQLite tests; EF model check; SQLite/MariaDB scripts; disposable SQLite backfill and fresh application startup; full backend | Repository root | Make Workspace and durable Strategy/CV usage owner-safe, idempotent and independent of deletable private history | PASS — focused 28/28 and backend 663/663; no pending model changes; both providers generate bounded additive DDL; SQLite backfills the synthetic legacy row exactly once; fresh runtime applies through `20260815175236_AddCrossFeatureAiUsage` and serves `/health` | Synthetic local rows only; no provider/model call, MariaDB server, production migration or worker activation. CV retains a conservative reservation and older synchronous AI paths are not yet universal | Main durable usage boundary implemented; remaining synchronous producers stay tracked under POL-001 |
|
| V-181 | AI usage meter/operation/workspace/export/deletion real-SQLite tests; EF model check; SQLite/MariaDB scripts; disposable SQLite backfill and fresh application startup; full backend | Repository root | Make Workspace and durable Strategy/CV usage owner-safe, idempotent and independent of deletable private history | PASS — focused 28/28 and backend 663/663; no pending model changes; both providers generate bounded additive DDL; SQLite backfills the synthetic legacy row exactly once; fresh runtime applies through `20260815175236_AddCrossFeatureAiUsage` and serves `/health` | Synthetic local rows only; no provider/model call, MariaDB server, production migration or worker activation. CV retains a conservative reservation and older synchronous AI paths are not yet universal | Main durable usage boundary implemented; remaining synchronous producers stay tracked under POL-001 |
|
||||||
| V-182 | Real ASP.NET Identity data-protection token integration on SQLite; focused auth tests; full backend | Repository root | Close SEC-005B expiry/replay/custom-username proof without SMTP or production | PASS — valid confirmation succeeds once, replay and zero-lifetime expiry return the same generic failure, a real change-email token preserves a custom username and cannot replay; focused 39/39 and backend 666/666 | Synthetic addresses and ephemeral local data-protection keys only; no email, browser, MariaDB or production call | SEC-005B local token-state gap closed |
|
| V-182 | Real ASP.NET Identity data-protection token integration on SQLite; focused auth tests; full backend | Repository root | Close SEC-005B expiry/replay/custom-username proof without SMTP or production | PASS — valid confirmation succeeds once, replay and zero-lifetime expiry return the same generic failure, a real change-email token preserves a custom username and cannot replay; focused 39/39 and backend 666/666 | Synthetic addresses and ephemeral local data-protection keys only; no email, browser, MariaDB or production call | SEC-005B local token-state gap closed |
|
||||||
|
| V-183 | Owner-filtered job-choice API test; correspondence Jest; frontend production build | Repository root / `job-tracker-ui` | Remove the email compose/thread-move selectors' false 100-job ceiling | PASS — backend search finds the oldest target among 130 owned rows and excludes another tenant; correspondence 20/20 proves debounced server search, compose selection and thread-move selection; TypeScript/production build passes | Synthetic rows/JSDOM only; no provider, email or production action | MAIL-001 exhaustive job selection gap closed |
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ Status: `IN PROGRESS`. Canonical hub routing, provider-neutral reads, explicit-c
|
|||||||
- Compose new email loads the authenticated user's recent job list and offers only connected providers that report send consent. Read-only/disconnected providers are absent from the selector and the send API still rechecks connection capability.
|
- Compose new email loads the authenticated user's recent job list and offers only connected providers that report send consent. Read-only/disconnected providers are absent from the selector and the send API still rechecks connection capability.
|
||||||
- The job and provider selectors have explicit accessible labels. Missing jobs and missing send consent produce visible guidance instead of a non-explanatory disabled path.
|
- The job and provider selectors have explicit accessible labels. Missing jobs and missing send consent produce visible guidance instead of a non-explanatory disabled path.
|
||||||
- Starting creates a blank local threadless draft; Save draft and Review and send reuse the same bounded persistence, UUID, revision conflict, confirmation and delivery paths as replies.
|
- Starting creates a blank local threadless draft; Save draft and Review and send reuse the same bounded persistence, UUID, revision conflict, confirmation and delivery paths as replies.
|
||||||
- Focused inbox passes 13/13, full frontend 198/198 and production build pass. The hub selection is currently bounded to 100 recent owned jobs; this is recorded rather than presented as exhaustive.
|
- The compose and linked-thread move selectors use an owner-filtered server search rather than pretending a large page size is exhaustive. Search evaluates all owned, non-deleted applications and returns bounded compact choices. Focused correspondence passes 20/20, the owner/isolation endpoint slice passes 4/4, and the production build passes.
|
||||||
|
|
||||||
## Remaining MAIL-001 work
|
## Remaining MAIL-001 work
|
||||||
|
|
||||||
|
|||||||
@@ -668,7 +668,7 @@ This queue records the highest-value work that can proceed without production cr
|
|||||||
- **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. 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.
|
||||||
- **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:** full account deletion remains SEC-009; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; searchable selection beyond the 100 recent jobs; browser/production verification. Existing connections need explicit re-consent; IMAP remains read-only. No real email; uncertain sends need manual reconciliation. JT-019 blocks a clean full-chain SQLite rehearsal before the new draft migration.
|
- **Remaining work:** full account deletion remains SEC-009; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; browser/production verification. 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. JT-019 blocks a clean full-chain SQLite rehearsal before the new draft migration.
|
||||||
|
|
||||||
### JOBS-001 — Job-search source and assessment redesign
|
### JOBS-001 — Job-search source and assessment redesign
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -43,7 +44,6 @@ import {
|
|||||||
GmailStatus,
|
GmailStatus,
|
||||||
GmailThreadRefreshResult,
|
GmailThreadRefreshResult,
|
||||||
GmailUnlinkResult,
|
GmailUnlinkResult,
|
||||||
JobApplication,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -103,9 +103,7 @@ function formatReasonLabel(label: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PagedResult<T> {
|
type JobChoice = { id: number; jobTitle: string; companyName: string };
|
||||||
items: T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CorrespondenceJobContext = {
|
export type CorrespondenceJobContext = {
|
||||||
companyName?: string | null;
|
companyName?: string | null;
|
||||||
@@ -153,7 +151,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
|||||||
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
||||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||||
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
|
const [availableJobs, setAvailableJobs] = useState<JobChoice[]>([]);
|
||||||
|
const [jobChoiceQuery, setJobChoiceQuery] = useState("");
|
||||||
|
const [jobChoicesLoading, setJobChoicesLoading] = useState(false);
|
||||||
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
|
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
|
||||||
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
|
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
|
||||||
const [manageNote, setManageNote] = useState("");
|
const [manageNote, setManageNote] = useState("");
|
||||||
@@ -195,12 +195,15 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
|||||||
}
|
}
|
||||||
}, [jobId, toast]);
|
}, [jobId, toast]);
|
||||||
|
|
||||||
const loadAvailableJobs = useCallback(async () => {
|
const loadAvailableJobs = useCallback(async (query?: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
|
setJobChoicesLoading(true);
|
||||||
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
|
const res = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: query?.trim() || undefined, limit: 50 } });
|
||||||
|
setAvailableJobs((res.data ?? []).filter((item) => item.id !== jobId));
|
||||||
} catch {
|
} catch {
|
||||||
setAvailableJobs([]);
|
setAvailableJobs([]);
|
||||||
|
} finally {
|
||||||
|
setJobChoicesLoading(false);
|
||||||
}
|
}
|
||||||
}, [jobId]);
|
}, [jobId]);
|
||||||
|
|
||||||
@@ -257,8 +260,13 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadGmailStatus();
|
void loadGmailStatus();
|
||||||
void loadAvailableJobs();
|
}, [loadGmailStatus]);
|
||||||
}, [loadAvailableJobs, loadGmailStatus]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manageThreadId) return;
|
||||||
|
const timer = window.setTimeout(() => void loadAvailableJobs(jobChoiceQuery), jobChoiceQuery.trim() ? 250 : 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [jobChoiceQuery, loadAvailableJobs, manageThreadId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
||||||
@@ -405,6 +413,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
|||||||
const openManageThread = (threadId: string) => {
|
const openManageThread = (threadId: string) => {
|
||||||
setManageThreadId(threadId);
|
setManageThreadId(threadId);
|
||||||
setManageTargetJobId(jobId);
|
setManageTargetJobId(jobId);
|
||||||
|
setJobChoiceQuery("");
|
||||||
setManageNote("");
|
setManageNote("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -443,7 +452,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
|||||||
await loadGmailMatches(gmailQuery);
|
await loadGmailMatches(gmailQuery);
|
||||||
setManageThreadId(null);
|
setManageThreadId(null);
|
||||||
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
|
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
|
||||||
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
|
toast(`Moved thread to ${targetJob?.companyName || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
|
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -561,21 +570,18 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
|||||||
minRows={2}
|
minRows={2}
|
||||||
placeholder="Why this thread should stay in review or move to another job."
|
placeholder="Why this thread should stay in review or move to another job."
|
||||||
/>
|
/>
|
||||||
<FormControl fullWidth>
|
<Autocomplete
|
||||||
<InputLabel>Move to job</InputLabel>
|
options={availableJobs}
|
||||||
<Select
|
loading={jobChoicesLoading}
|
||||||
value={String(manageTargetJobId)}
|
value={availableJobs.find((item) => item.id === manageTargetJobId) ?? null}
|
||||||
label="Move to job"
|
onInputChange={(_, value, reason) => {
|
||||||
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
|
if (reason === "input" || reason === "clear") setJobChoiceQuery(value);
|
||||||
>
|
}}
|
||||||
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
|
onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)}
|
||||||
{availableJobs.map((item) => (
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
<MenuItem key={item.id} value={String(item.id)}>
|
getOptionLabel={(item) => `${item.companyName || "Unknown company"} • ${item.jobTitle}`}
|
||||||
{item.company?.name || "Unknown company"} • {item.jobTitle}
|
renderInput={(params) => <TextField {...params} label="Move to job" placeholder="Search every job" />}
|
||||||
</MenuItem>
|
/>
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
|
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
|
||||||
|
|||||||
@@ -51,28 +51,20 @@ describe("correspondence Gmail import", () => {
|
|||||||
correspondenceMessages = [];
|
correspondenceMessages = [];
|
||||||
|
|
||||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||||
if (url === "/jobapplications") {
|
if (url === "/jobapplications/choices") {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
data: {
|
data: [
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
id: 42,
|
id: 42,
|
||||||
jobTitle: "Backend Developer",
|
jobTitle: "Backend Developer",
|
||||||
status: "Applied",
|
companyName: "Acme",
|
||||||
dateApplied: new Date().toISOString(),
|
|
||||||
daysSince: 3,
|
|
||||||
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 77,
|
id: 77,
|
||||||
jobTitle: "Platform Engineer",
|
jobTitle: "Platform Engineer",
|
||||||
status: "Applied",
|
companyName: "Beta",
|
||||||
dateApplied: new Date().toISOString(),
|
|
||||||
daysSince: 1,
|
|
||||||
company: { name: "Beta" },
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
if (url === "/jobapplications/42") {
|
if (url === "/jobapplications/42") {
|
||||||
@@ -377,7 +369,7 @@ describe("correspondence Gmail import", () => {
|
|||||||
renderDialog();
|
renderDialog();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
||||||
fireEvent.mouseDown((await screen.findAllByRole("combobox")).slice(-1)[0]);
|
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /move to job/i }));
|
||||||
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
|
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
|
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
},
|
},
|
||||||
] } as any);
|
] } 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 === '/email/message') return Promise.resolve({ data: {
|
if (url === '/email/message') return Promise.resolve({ data: {
|
||||||
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
|
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
|
||||||
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
|
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
|
||||||
@@ -328,9 +329,9 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
|
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
|
||||||
{ provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false },
|
{ provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false },
|
||||||
] } as any);
|
] } as any);
|
||||||
if (url === '/jobapplications') return Promise.resolve({ data: { items: [
|
if (url === '/jobapplications/choices') return Promise.resolve({ data: [
|
||||||
{ id: 42, jobTitle: 'Backend Engineer', company: { name: 'Acme Systems' } },
|
{ id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' },
|
||||||
] } } as any);
|
] } as any);
|
||||||
return original!(url, config);
|
return original!(url, config);
|
||||||
});
|
});
|
||||||
mockedApi.post.mockImplementation((url: string) => {
|
mockedApi.post.mockImplementation((url: string) => {
|
||||||
@@ -371,6 +372,34 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('searches the complete server-side job choice set while composing', async () => {
|
||||||
|
const original = mockedApi.get.getMockImplementation();
|
||||||
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||||
|
if (url === '/email/providers') return Promise.resolve({ data: [
|
||||||
|
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
|
||||||
|
] } as any);
|
||||||
|
if (url === '/jobapplications/choices') {
|
||||||
|
return Promise.resolve({ data: config?.params?.q === 'Historic'
|
||||||
|
? [{ id: 7, jobTitle: 'Historic target role', companyName: 'Archive Co' }]
|
||||||
|
: [{ id: 42, jobTitle: 'Recent role', companyName: 'Acme Systems' }] } as any);
|
||||||
|
}
|
||||||
|
return original!(url, config);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
const compose = await screen.findByRole('button', { name: /compose new email/i });
|
||||||
|
await waitFor(() => expect(compose).toBeEnabled());
|
||||||
|
fireEvent.click(compose);
|
||||||
|
fireEvent.change(screen.getByRole('combobox', { name: /^job$/i }), { target: { value: 'Historic' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', {
|
||||||
|
params: { q: 'Historic', limit: 20 },
|
||||||
|
}));
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: /Archive Co.*Historic target role/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /start draft/i }));
|
||||||
|
expect(screen.getByText(/Archive Co.*Historic target role/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test('does not offer retry when delivery is uncertain', async () => {
|
test('does not offer retry when delivery is uncertain', async () => {
|
||||||
const original = mockedApi.get.getMockImplementation();
|
const original = mockedApi.get.getMockImplementation();
|
||||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Alert,
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -53,7 +54,7 @@ type EmailProviderStatus = {
|
|||||||
type JobChoice = {
|
type JobChoice = {
|
||||||
id: number;
|
id: number;
|
||||||
jobTitle: string;
|
jobTitle: string;
|
||||||
company?: { name?: string | null } | null;
|
companyName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
|
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
|
||||||
@@ -119,6 +120,8 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
||||||
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||||
const [jobsLoaded, setJobsLoaded] = useState(false);
|
const [jobsLoaded, setJobsLoaded] = useState(false);
|
||||||
|
const [jobsLoading, setJobsLoading] = useState(false);
|
||||||
|
const [jobQuery, setJobQuery] = useState("");
|
||||||
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
||||||
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
||||||
const [composeProvider, setComposeProvider] = useState("");
|
const [composeProvider, setComposeProvider] = useState("");
|
||||||
@@ -178,11 +181,18 @@ export default function CorrespondenceInboxPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<{ items?: JobChoice[] }>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } })
|
const timer = window.setTimeout(() => {
|
||||||
.then((response) => setJobs(Array.isArray(response.data?.items) ? response.data.items : []))
|
setJobsLoading(true);
|
||||||
|
api.get<JobChoice[]>("/jobapplications/choices", { params: { q: jobQuery.trim() || undefined, limit: 20 } })
|
||||||
|
.then((response) => setJobs(Array.isArray(response.data) ? response.data : []))
|
||||||
.catch(() => setJobs([]))
|
.catch(() => setJobs([]))
|
||||||
.finally(() => setJobsLoaded(true));
|
.finally(() => {
|
||||||
}, []);
|
setJobsLoaded(true);
|
||||||
|
setJobsLoading(false);
|
||||||
|
});
|
||||||
|
}, jobQuery.trim() ? 250 : 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [jobQuery]);
|
||||||
|
|
||||||
const loadStoredDrafts = useCallback(async () => {
|
const loadStoredDrafts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -312,7 +322,7 @@ export default function CorrespondenceInboxPage() {
|
|||||||
}))) return;
|
}))) return;
|
||||||
setDraft({
|
setDraft({
|
||||||
jobApplicationId: job.id,
|
jobApplicationId: job.id,
|
||||||
companyName: job.company?.name || "Unknown company",
|
companyName: job.companyName || "Unknown company",
|
||||||
jobTitle: job.jobTitle || "Unknown role",
|
jobTitle: job.jobTitle || "Unknown role",
|
||||||
provider: provider.provider,
|
provider: provider.provider,
|
||||||
providerName: provider.displayName,
|
providerName: provider.displayName,
|
||||||
@@ -558,12 +568,18 @@ export default function CorrespondenceInboxPage() {
|
|||||||
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
||||||
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>Compose new email</Typography>
|
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>Compose new email</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
||||||
<FormControl fullWidth>
|
<Autocomplete
|
||||||
<InputLabel id="compose-job-label">Job</InputLabel>
|
options={jobs}
|
||||||
<Select labelId="compose-job-label" value={composeJobId} label="Job" onChange={(event) => setComposeJobId(Number(event.target.value))}>
|
loading={jobsLoading}
|
||||||
{jobs.map((job) => <MenuItem key={job.id} value={job.id}>{job.company?.name || "Unknown company"} · {job.jobTitle}</MenuItem>)}
|
value={jobs.find((job) => job.id === composeJobId) ?? null}
|
||||||
</Select>
|
onInputChange={(_, value, reason) => {
|
||||||
</FormControl>
|
if (reason === "input" || reason === "clear") setJobQuery(value);
|
||||||
|
}}
|
||||||
|
onChange={(_, value) => setComposeJobId(value?.id ?? "")}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
getOptionLabel={(job) => `${job.companyName || "Unknown company"} · ${job.jobTitle}`}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Job" placeholder="Search every job" />}
|
||||||
|
/>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel id="compose-provider-label">Sending provider</InputLabel>
|
<InputLabel id="compose-provider-label">Sending provider</InputLabel>
|
||||||
<Select labelId="compose-provider-label" value={composeProvider} label="Sending provider" onChange={(event) => setComposeProvider(String(event.target.value))}>
|
<Select labelId="compose-provider-label" value={composeProvider} label="Sending provider" onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||||
|
|||||||
Reference in New Issue
Block a user