feat(email): confirm hub thread unlink
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user