feat(email): confirm hub thread unlink

This commit is contained in:
cesnimda
2026-08-10 09:40:59 +02:00
parent 73ee095795
commit 1dabbeb233
3 changed files with 88 additions and 1 deletions
@@ -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<IGmailOAuthService>(), "user-1");
var result = await controller.UnlinkThread(new UnlinkGmailThreadRequest(job.Id, "other-thread", null, "review"), CancellationToken.None);
Assert.IsType<NotFoundObjectResult>(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()
{
@@ -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) => {
@@ -108,6 +108,7 @@ export default function CorrespondenceInboxPage() {
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
const [sendError, setSendError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [unlinkingThreadId, setUnlinkingThreadId] = useState<string | null>(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 (
<Paper
sx={{
@@ -401,6 +433,11 @@ export default function CorrespondenceInboxPage() {
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? "Hide message" : "View message"}</Button>
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
{item.provider === "gmail" && item.externalThreadId ? (
<Button size="small" color="warning" variant="text" disabled={unlinkingThreadId === item.externalThreadId} onClick={() => void unlinkGmailThread(item)}>
{unlinkingThreadId === item.externalThreadId ? "Unlinking…" : "Unlink thread"}
</Button>
) : null}
</Box>
</Box>
{selectedMessageId === item.id ? (