diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index 46264fc..5f53879 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -853,6 +853,35 @@ public sealed class GmailControllerTests Assert.Equal("Need manual review", decision.Note); } + [Fact] + public async Task Unlink_thread_cannot_remove_another_users_messages() + { + await using var db = CreateDb(); + var company = new Company { Name = "Other company", OwnerUserId = "user-2" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Private role", CompanyId = company.Id, OwnerUserId = "user-2" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Content = "Private message", + ExternalMessageId = "other-message", + ExternalThreadId = "other-thread" + }); + await db.SaveChangesAsync(); + + var controller = CreateController(db, Mock.Of(), "user-1"); + var result = await controller.UnlinkThread(new UnlinkGmailThreadRequest(job.Id, "other-thread", null, "review"), CancellationToken.None); + + Assert.IsType(result.Result); + Assert.Single(await db.Correspondences.IgnoreQueryFilters().ToListAsync()); + Assert.Empty(await db.GmailReviewDecisions.IgnoreQueryFilters().ToListAsync()); + } + [Fact] public async Task Relink_thread_can_move_messages_from_other_jobs() { diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 5c80181..abab7d2 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'; import { ToastProvider } from './toast'; import { ConfirmProvider } from './confirm'; @@ -120,6 +120,27 @@ describe('CorrespondenceInboxPage', () => { }); }); + 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(); + + fireEvent.click(await screen.findByRole('button', { name: /^unlink thread$/i })); + expect(mockedApi.post).not.toHaveBeenCalled(); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/provider copy is not deleted/i)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: /^unlink thread$/i })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/gmail/unlink-thread', { + jobApplicationId: 42, + threadId: 'thread-1', + note: 'Unlinked from Job email hub', + nextDecision: 'review', + })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence', expect.anything())); + expect(await screen.findByText(/returned to recruitment review/i)).toBeInTheDocument(); + }); + test('falls back to the saved copy when provider detail is unavailable', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index 10363aa..380311a 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -108,6 +108,7 @@ export default function CorrespondenceInboxPage() { const [sendResult, setSendResult] = useState(null); const [sendError, setSendError] = useState(null); const [sending, setSending] = useState(false); + const [unlinkingThreadId, setUnlinkingThreadId] = useState(null); const load = useCallback(async () => { setLoading(true); @@ -281,6 +282,37 @@ export default function CorrespondenceInboxPage() { setSendError(null); }; + const unlinkGmailThread = async (item: CorrespondenceInboxItem) => { + if (item.provider !== "gmail" || !item.externalThreadId || unlinkingThreadId) return; + if (!(await confirm({ + title: "Unlink Gmail thread?", + message: `Remove this Gmail thread from ${item.companyName || "this job"} and return it to recruitment review? The provider copy is not deleted.`, + confirmLabel: "Unlink thread", + destructive: true, + }))) return; + + setUnlinkingThreadId(item.externalThreadId); + try { + await api.post("/gmail/unlink-thread", { + jobApplicationId: item.jobApplicationId, + threadId: item.externalThreadId, + note: "Unlinked from Job email hub", + nextDecision: "review", + }); + if (selectedMessageId === item.id) { + detailRequest.current += 1; + setSelectedMessageId(null); + setMessageDetail(null); + } + await load(); + toast("Gmail thread returned to recruitment review.", "success"); + } catch (error) { + toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error"); + } finally { + setUnlinkingThreadId(null); + } + }; + return ( 0 ? : null} + {item.provider === "gmail" && item.externalThreadId ? ( + + ) : null} {selectedMessageId === item.id ? (