diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index e5abc49..07c94b7 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -1,6 +1,6 @@ import { useAuth } from "@/domains/auth/application/AuthContext"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { DashboardTab } from "./components/dashboard/DashboardTab"; import { HelpTab } from "./components/help/HelpTab"; import { HomeTab } from "./components/home/HomeTab"; @@ -134,11 +134,11 @@ function scoreJobWithTechnologies( matchedTechnologies.length === 0 ? 45 : Math.min( - 99, - 55 + - Math.round(coverage * 35) + - Math.min(matchedTechnologies.length * 4, 9), - ); + 99, + 55 + + Math.round(coverage * 35) + + Math.min(matchedTechnologies.length * 4, 9), + ); return { ...job, @@ -163,6 +163,7 @@ export default function NewDashboardPage() { const [countryFilter, setCountryFilter] = useState("Todos"); const [matchSort, setMatchSort] = useState("default"); const [selectedJobId, setSelectedJobId] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); const [isAddJobOpen, setIsAddJobOpen] = useState(false); const [toast, setToast] = useState(""); const [hasUserChangedJobFilters, setHasUserChangedJobFilters] = @@ -185,14 +186,31 @@ export default function NewDashboardPage() { () => getModelFilterFromJobTypes(searchPreferences.jobTypes), [searchPreferences.jobTypes], ); + + useEffect(() => { + const jobId = searchParams.get("jobId"); + if (!jobId) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- sincroniza o jobId vindo da URL (navegação externa) com o estado local; execução única por navegação, não é loop de renderização. + setSelectedJobId(jobId); + setSelectedJobId(jobId); + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.delete("jobId"); + return next; + }, + { replace: true }, + ); + }, [searchParams, setSearchParams]); + const initialRecommendationSearch = useMemo( () => isLoadingUserData ? null : { - keywords: [], - filters: modelFilterToApiFilter(preferredModelFilter), - }, + keywords: [], + filters: modelFilterToApiFilter(preferredModelFilter), + }, [isLoadingUserData, preferredModelFilter], ); const { diff --git a/frontend/src/domains/new_dashboard/components/layout/Header.tsx b/frontend/src/domains/new_dashboard/components/layout/Header.tsx index e8a656e..43f96e4 100644 --- a/frontend/src/domains/new_dashboard/components/layout/Header.tsx +++ b/frontend/src/domains/new_dashboard/components/layout/Header.tsx @@ -7,6 +7,7 @@ import { clearDashboardNotifications, getDashboardNotificationFeed, markDashboardNotificationsRead, + markSingleNotificationRead, } from "../../infrastructure/notificationsApi"; import type { Message, Notification, UserProfile } from "../../types"; import { @@ -154,6 +155,8 @@ export function Header({ text: detail.item!.text, type: detail.item!.type, date: detail.item!.date, + jobId: detail.item!.jobId, + isRead: detail.item!.isRead ?? false, }, ...current.filter((item) => item.text !== detail.item!.text), ]); @@ -320,20 +323,43 @@ export function Header({
- {menuNotifications.length > 0 ? ( - menuNotifications.map((notification) => ( -
- -
-

- {notification.text} -

- - {notification.date} - -
-
- )) + {menuNotifications.length > 0 ? ( + menuNotifications.map((notification) => { + const jobId = notification.jobId; + const isRead = notification.isRead ?? false; + return ( +
{ + setShowNotificationsMenu(false); + setMenuNotifications((current) => + current.map((item) => item.id === notification.id ? { ...item, isRead: true } : item,), + ); + void markSingleNotificationRead(String(notification.id)).catch(() => { + }); + navigate(`/dashboard?jobId=${jobId}`); + } + : undefined + } + className={`flex gap-3 ${jobId ? "cursor-pointer hover:opacity-50" : ""} ${ isRead ? "opacity-50" : "" }`} + > + +
+

+ {notification.text} +

+ + {notification.date} + +
+
+ ); + }) ) : (

Nenhuma notificação recente. diff --git a/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts b/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts index 2447efa..50bcfbb 100644 --- a/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts +++ b/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts @@ -77,6 +77,8 @@ function notifyJobEvent(job: Job, status?: JobStatus) { text, type: isApplied ? "success" : "info", date: "Agora", + jobId: job.id, + isRead: false, }, }); } diff --git a/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts b/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts index 16bbbc7..ecf9fa8 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts +++ b/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts @@ -10,6 +10,8 @@ const ApiNotificationSchema = z.object({ message: z.string(), readAt: z.string().nullable().optional(), createdAt: z.string(), + entityType: z.string().nullable().optional(), + entityId: z.string().nullable().optional(), }); const ApiNotificationsResponseSchema = z.object({ @@ -70,6 +72,8 @@ function toNotification(item: ApiNotification): Notification { text: item.message, type: notificationType(item.type), date: formatNotificationDate(item.createdAt), + jobId: item.entityType === "job" ? item.entityId ?? undefined : undefined, + isRead: Boolean(item.readAt) }; } @@ -114,6 +118,10 @@ export async function markDashboardNotificationsRead(channel: NotificationChanne }); } +export async function markSingleNotificationRead(notificationId: string) { + await api.patch(`/notifications/${notificationId}/read`); +} + export async function clearDashboardNotifications(channel: NotificationChannel) { await api.delete("/notifications", { params: { channel }, diff --git a/frontend/src/domains/new_dashboard/types/user.ts b/frontend/src/domains/new_dashboard/types/user.ts index 538c795..a47792d 100644 --- a/frontend/src/domains/new_dashboard/types/user.ts +++ b/frontend/src/domains/new_dashboard/types/user.ts @@ -46,6 +46,8 @@ export const NotificationSchema = z.object({ text: z.string().min(1), type: z.enum(["info", "success", "match"]), date: z.string().min(1), + jobId: z.string().optional(), + isRead: z.boolean().optional(), }); export const MessageSchema = z.object({ diff --git a/frontend/src/domains/new_dashboard/utils/notificationEvents.ts b/frontend/src/domains/new_dashboard/utils/notificationEvents.ts index 4d79e2a..aa3a1a4 100644 --- a/frontend/src/domains/new_dashboard/utils/notificationEvents.ts +++ b/frontend/src/domains/new_dashboard/utils/notificationEvents.ts @@ -11,6 +11,8 @@ export type DashboardNotificationsRefreshDetail = { date: string; sender?: string; origin?: "recruiter" | "mentor" | "system"; + jobId?: string; + isRead?: boolean; }; }; diff --git a/frontend/tests/unit/new_dashboard/api.test.ts b/frontend/tests/unit/new_dashboard/api.test.ts index 42f4312..1f2b2c5 100644 --- a/frontend/tests/unit/new_dashboard/api.test.ts +++ b/frontend/tests/unit/new_dashboard/api.test.ts @@ -23,6 +23,7 @@ import { clearDashboardNotifications, getDashboardNotificationFeed as getNotificationFeed, markDashboardNotificationsRead as markNotificationsRead, + markSingleNotificationRead, } from "@/domains/new_dashboard/infrastructure/notificationsApi"; import { getUserPreferences, @@ -184,6 +185,55 @@ describe("new_dashboard api adapters", () => { }); }); +it("marca uma única notificação como lida pela rota correta", async () => { + apiMock.patch.mockResolvedValue({ data: {} }); + + await markSingleNotificationRead("notification-42"); + + expect(apiMock.patch).toHaveBeenCalledWith( + "/notifications/notification-42/read", + ); +}); + +it("mapeia entityId para jobId apenas quando entityType é job, e readAt para isRead", async () => { + apiMock.get.mockResolvedValueOnce({ + data: { + unreadCount: 2, + notifications: [ + { + id: "job-linked", + channel: "notification", + type: "job_applied", + title: "Candidatura enviada", + message: "Sua candidatura foi registrada.", + readAt: null, + createdAt: new Date().toISOString(), + entityType: "job", + entityId: "job-99", + }, + { + id: "mentor-linked", + channel: "notification", + type: "mentor_message", + title: "Mentoria", + message: "Nova mensagem do mentor.", + readAt: "2026-08-01T10:00:00.000Z", + createdAt: new Date().toISOString(), + entityType: "mentor", + entityId: "mentor-5", + }, + ], + }, + }); + + const result = await getNotificationFeed("notification"); + + expect(result.notifications).toMatchObject([ + { id: "job-linked", jobId: "job-99", isRead: false }, + { id: "mentor-linked", jobId: undefined, isRead: true }, + ]); +}); + it("normaliza tipos, origens e datas do feed de notificações", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-21T12:00:00.000Z")); diff --git a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx index d449ffc..f78f528 100644 --- a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx +++ b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx @@ -7,9 +7,10 @@ import { MentoringTab } from "@/domains/new_dashboard/components/mentoring/Mento import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { Modal } from "@/domains/new_dashboard/components/shared/Modal"; import { - clearDashboardNotifications, - getDashboardNotificationFeed, - markDashboardNotificationsRead, + clearDashboardNotifications, + getDashboardNotificationFeed, + markDashboardNotificationsRead, + markSingleNotificationRead, } from "@/domains/new_dashboard/infrastructure/notificationsApi"; import type { Job, UserProfile } from "@/domains/new_dashboard/types"; import { DASHBOARD_NOTIFICATIONS_REFRESH_EVENT } from "@/domains/new_dashboard/utils/notificationEvents"; @@ -28,6 +29,18 @@ vi.mock("@/shared/hooks/useTheme", () => ({ useTheme: () => mockUseTheme(), })); +const mockNavigate = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ getDashboardNotificationFeed: vi.fn().mockResolvedValue({ messages: [], @@ -36,6 +49,7 @@ vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ }), markDashboardNotificationsRead: vi.fn().mockResolvedValue(undefined), clearDashboardNotifications: vi.fn().mockResolvedValue(undefined), + markSingleNotificationRead: vi.fn().mockResolvedValue(undefined), })); function renderWithRouter(ui: React.ReactElement) { @@ -88,6 +102,8 @@ describe("new_dashboard branch coverage", () => { vi.mocked(getDashboardNotificationFeed).mockReset(); vi.mocked(markDashboardNotificationsRead).mockReset(); vi.mocked(clearDashboardNotifications).mockReset(); + vi.mocked(markSingleNotificationRead).mockReset(); + mockNavigate.mockReset(); vi.mocked(getDashboardNotificationFeed).mockResolvedValue({ messages: [], notifications: [], @@ -95,6 +111,7 @@ describe("new_dashboard branch coverage", () => { } as never); vi.mocked(markDashboardNotificationsRead).mockResolvedValue(undefined); vi.mocked(clearDashboardNotifications).mockResolvedValue(undefined); + vi.mocked(markSingleNotificationRead).mockResolvedValue(undefined); mockUseAuth.mockReturnValue({ user: null, logout: vi.fn(), @@ -229,6 +246,157 @@ describe("new_dashboard branch coverage", () => { }); }); + it("navega até a vaga e marca como lida ao clicar em notificação com jobId", async () => { + mockUseAuth.mockReturnValue({ + user: { email: "ana@exemplo.com", name: "Ana" }, + logout: vi.fn(), + }); + + renderWithRouter(

); + + window.dispatchEvent( + new CustomEvent(DASHBOARD_NOTIFICATIONS_REFRESH_EVENT, { + detail: { + channel: "notification", + incrementUnread: true, + item: { + id: "local:job:job-42:123", + text: "Sua candidatura foi registrada.", + type: "success", + date: "Agora", + jobId: "job-42", + isRead: false, + }, + }, + }), + ); + + fireEvent.click(screen.getByLabelText("Notificações")); + + const notification = await screen.findByText( + "Sua candidatura foi registrada.", + ); + + fireEvent.click(notification); + + expect(mockNavigate).toHaveBeenCalledWith("/dashboard?jobId=job-42"); + expect(markSingleNotificationRead).toHaveBeenCalledWith("local:job:job-42:123"); + }); + + it("não navega nem marca como lida ao clicar em notificação sem jobId", async () => { + mockUseAuth.mockReturnValue({ + user: { email: "ana@exemplo.com", name: "Ana" }, + logout: vi.fn(), + }); + + renderWithRouter(
); + + window.dispatchEvent( + new CustomEvent(DASHBOARD_NOTIFICATIONS_REFRESH_EVENT, { + detail: { + channel: "notification", + incrementUnread: true, + item: { + id: "notification-sem-vaga", + text: "Novo conteúdo de mentoria disponível.", + type: "info", + date: "Agora", + }, + }, + }), + ); + + fireEvent.click(screen.getByLabelText("Notificações")); + + const notification = await screen.findByText( + "Novo conteúdo de mentoria disponível.", + ); + + fireEvent.click(notification); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(markSingleNotificationRead).not.toHaveBeenCalled(); + }); + + it("usa título padrão quando a rota atual não está mapeada", () => { + mockUseAuth.mockReturnValue({ user: null, logout: vi.fn() }); + + render( + +
+ , + ); + + expect(screen.getByRole("heading", { name: "Início" })).toBeInTheDocument(); + }); + + it("fecha os menus abertos ao clicar fora da área de ações", () => { + mockUseAuth.mockReturnValue({ user: null, logout: vi.fn() }); + + renderWithRouter(
); + + fireEvent.click(screen.getByLabelText("Notificações")); + expect(screen.getByText("Notificações Recentes")).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + + expect(screen.queryByText("Notificações Recentes")).not.toBeInTheDocument(); + }); + + it("inclui mensagem local recebida por evento sem depender de reload", async () => { + mockUseAuth.mockReturnValue({ + user: { email: "ana@exemplo.com", name: "Ana" }, + logout: vi.fn(), + }); + + renderWithRouter(
); + + window.dispatchEvent( + new CustomEvent(DASHBOARD_NOTIFICATIONS_REFRESH_EVENT, { + detail: { + channel: "message", + incrementUnread: true, + item: { + id: "local:message", + sender: "Mentor", + text: "Nova mensagem do mentor.", + date: "Agora", + }, + }, + }), + ); + + fireEvent.click(screen.getByLabelText("Mensagens")); + + await waitFor(() => { + expect(screen.getByText("Nova mensagem do mentor.")).toBeInTheDocument(); + }); + }); + + it("recarrega feeds via API quando o evento não traz um item", async () => { + mockUseAuth.mockReturnValue({ + user: { email: "ana@exemplo.com", name: "Ana" }, + logout: vi.fn(), + }); + + renderWithRouter(
); + + await waitFor(() => { + expect(getDashboardNotificationFeed).toHaveBeenCalled(); + }); + vi.mocked(getDashboardNotificationFeed).mockClear(); + + window.dispatchEvent( + new CustomEvent(DASHBOARD_NOTIFICATIONS_REFRESH_EVENT, { + detail: { channel: "notification" }, + }), + ); + + await waitFor(() => { + expect(getDashboardNotificationFeed).toHaveBeenCalled(); + }); + }); + it("renderiza modal compartilhado sem subtítulo nem rodapé", () => { const onClose = vi.fn(); diff --git a/frontend/tests/unit/new_dashboard/hooks.test.tsx b/frontend/tests/unit/new_dashboard/hooks.test.tsx index cb304dc..c324eec 100644 --- a/frontend/tests/unit/new_dashboard/hooks.test.tsx +++ b/frontend/tests/unit/new_dashboard/hooks.test.tsx @@ -258,6 +258,8 @@ describe("useDashboardJobs", () => { item: expect.objectContaining({ text: expect.stringContaining("Sua candidatura para"), type: "success", + jobId: "tracked-1", + isRead: false, }), }), }), @@ -327,4 +329,156 @@ describe("useDashboardJobs", () => { expect(dashboardApiMock.createDashboardSavedJob).not.toHaveBeenCalled(); expect(dashboardApiMock.updateDashboardSavedJob).not.toHaveBeenCalled(); }); + + it("mantém vagas do cache quando busca de salvas falha, e reporta erro combinado", async () => { + const onError = vi.fn(); + localStorage.setItem( + "new-dashboard-tracked-jobs:user-1", + JSON.stringify([trackedJob]), + ); + dashboardApiMock.getDashboardSavedJobs.mockRejectedValueOnce( + new Error("falha ao buscar salvas"), + ); + + dashboardApiMock.searchDashboardJobs.mockRejectedValueOnce( + new Error("falha ao buscar recomendadas"), + ); + + const stableRecommendationSearch = { keywords: [], filters: {} }; + + const { result } = renderHook(() => + useDashboardJobs(user, { + onError, + initialRecommendationSearch: stableRecommendationSearch, + }), + ); + + await waitFor(() => { + expect(result.current.isLoadingJobs).toBe(false); + }); + + expect(result.current.trackedJobs).toEqual([trackedJob]); + expect(onError).toHaveBeenCalledWith( + "Não foi possível carregar suas vagas salvas. Não foi possível atualizar as vagas recomendadas.", + ); + }); + + it("retorna null ao tentar mudar status de vaga inexistente", async () => { + dashboardApiMock.getDashboardSavedJobs.mockResolvedValue([]); + dashboardApiMock.searchDashboardJobs.mockResolvedValue({ + jobs: [], + pagination: { + total: 0, + page: 1, + limit: 50, + totalPages: 1, + hasNext: false, + hasPrev: false, + }, + }); + + const { result } = renderHook(() => useDashboardJobs(user)); + + await waitFor(() => { + expect(result.current.isLoadingJobs).toBe(false); + }); + + let response; + await act(async () => { + response = await result.current.changeJobStatus("id-inexistente", "applied"); + }); + + expect(response).toBeNull(); + expect(dashboardApiMock.updateDashboardSavedJob).not.toHaveBeenCalled(); + expect(dashboardApiMock.createDashboardSavedJob).not.toHaveBeenCalled(); + }); + + it("não busca recomendações quando initialRecommendationSearch é nulo", async () => { + dashboardApiMock.getDashboardSavedJobs.mockResolvedValue([]); + + const { result } = renderHook(() => + useDashboardJobs(user, { initialRecommendationSearch: null }), + ); + + await waitFor(() => { + expect(result.current.isLoadingJobs).toBe(false); + }); + + expect(dashboardApiMock.searchDashboardJobs).not.toHaveBeenCalled(); + expect(result.current.recommendedJobs).toEqual([]); + }); + + it("reaproveita vaga existente sem chamar update quando status já é igual ao solicitado", async () => { + dashboardApiMock.getDashboardSavedJobs.mockResolvedValueOnce([]); + dashboardApiMock.searchDashboardJobs.mockResolvedValue({ + jobs: [recommendedJob], + pagination: { + total: 1, + page: 1, + limit: 50, + totalPages: 1, + hasNext: false, + hasPrev: false, + }, + }); + dashboardApiMock.createDashboardSavedJob.mockRejectedValueOnce( + new ApiError("CONFLICT", "duplicada", 409), + ); + dashboardApiMock.getDashboardSavedJobs.mockResolvedValueOnce([ + { ...recommendedJob, id: "existing-1", status: "saved" }, + ]); + + const { result } = renderHook(() => useDashboardJobs(user)); + + await waitFor(() => { + expect(result.current.isLoadingJobs).toBe(false); + }); + + let response; + await act(async () => { + response = await result.current.changeJobStatus( + recommendedJob.id, + "saved", + ); + }); + + expect(response).toMatchObject({ id: "existing-1", status: "saved" }); + expect(dashboardApiMock.updateDashboardSavedJob).not.toHaveBeenCalled(); + }); + + it("usa mensagem padrão quando o erro rejeitado não é uma instância de Error", async () => { + dashboardApiMock.getDashboardSavedJobs.mockResolvedValue([]); + dashboardApiMock.searchDashboardJobs + .mockResolvedValueOnce({ + jobs: [], + pagination: { + total: 0, + page: 1, + limit: 50, + totalPages: 1, + hasNext: false, + hasPrev: false, + }, + }) + .mockRejectedValueOnce("falha sem instância de Error"); + + const onError = vi.fn(); + const { result } = renderHook(() => + useDashboardJobs(user, { onError }), + ); + + await waitFor(() => { + expect(result.current.isLoadingJobs).toBe(false); + }); + + await expect( + result.current.refreshRecommendations(["React"], {}, 1, 50), + ).rejects.toBe("falha sem instância de Error"); + + expect(onError).toHaveBeenCalledWith( + "Não foi possível procurar novas vagas.", + ); + }); + + });