diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts index 3953538..e296542 100644 --- a/backend/src/modules/notifications/notifications.controller.ts +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -36,4 +36,13 @@ export class NotificationsController { ); return res.json(result); } + + async clear(req: Request, res: Response) { + const userId = this.requireUserId(req); + const result = await this.service.clear( + userId, + (req.query as { channel?: "notification" | "message" }).channel, + ); + return res.json(result); + } } diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 87d96f6..14e67f1 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -105,6 +105,20 @@ export class NotificationsService { return { updated: result.length }; } + async clear(userId: string, channel?: "notification" | "message") { + const result = await this.tx + .delete(userNotifications) + .where( + and( + eq(userNotifications.userId, userId), + channel ? eq(userNotifications.channel, channel) : undefined, + ), + ) + .returning({ id: userNotifications.id }); + + return { deleted: result.length }; + } + async createForSavedJob(userId: string, job: SavedJob) { const type = job.status === "applied" ? "job_applied" : "job_saved"; const title = diff --git a/backend/src/modules/notifications/schemas/notifications.schemas.ts b/backend/src/modules/notifications/schemas/notifications.schemas.ts index 9d1cca5..771fdb6 100644 --- a/backend/src/modules/notifications/schemas/notifications.schemas.ts +++ b/backend/src/modules/notifications/schemas/notifications.schemas.ts @@ -16,3 +16,7 @@ export type ListNotificationsQuery = z.infer< export const markAllNotificationsReadQuerySchema = z.object({ channel: z.enum(["notification", "message"]).optional(), }); + +export const clearNotificationsQuerySchema = z.object({ + channel: z.enum(["notification", "message"]).optional(), +}); diff --git a/backend/src/routes/jobs.routes.ts b/backend/src/routes/jobs.routes.ts index 1830753..635c2a3 100644 --- a/backend/src/routes/jobs.routes.ts +++ b/backend/src/routes/jobs.routes.ts @@ -10,6 +10,7 @@ import { logWarn } from "../logger"; import { getUserMatchTechnologies, MatchableJob, + MatchedJob, scoreJobWithTechnologies, } from "../modules/jobs/jobMatch.service"; import { NotificationsService } from "../modules/notifications/notifications.service"; @@ -258,6 +259,29 @@ function getKeywordsArray(query: Request["query"]): string[] { .filter(Boolean); } +function getMatchSort(query: Request["query"]): "asc" | "desc" | null { + const value = firstQueryValue(query.matchSort) || firstQueryValue(query.sort); + return value === "asc" || value === "desc" ? value : null; +} + +function sortJobsByMatch( + jobs: T[], + direction: "asc" | "desc", +) { + return [...jobs].sort((first, second) => { + const firstJob = first as { matchScore?: number | null }; + const secondJob = second as { matchScore?: number | null }; + const firstScore = + typeof firstJob.matchScore === "number" ? firstJob.matchScore : 0; + const secondScore = + typeof secondJob.matchScore === "number" ? secondJob.matchScore : 0; + + return direction === "desc" + ? secondScore - firstScore + : firstScore - secondScore; + }); +} + async function legacyResolveIds( keywordsArray: string[], ): Promise<{ ids: string[]; source: string }> { @@ -294,14 +318,22 @@ async function enrichJobsWithProfileMatch( req: Request, jobs: unknown[], technologies: MatchTechnology[], + options: { notifyHighMatches?: boolean } = {}, ) { if (technologies.length === 0) return jobs; const matchedJobs = jobs.map((job) => scoreJobWithTechnologies(job as MatchableJob, technologies), ); + if (options.notifyHighMatches === false) return matchedJobs; + + await notifyHighMatchJobs(req, matchedJobs); + return matchedJobs; +} + +async function notifyHighMatchJobs(req: Request, matchedJobs: MatchedJob[]) { const userId = req.session?.userId; - if (!userId) return matchedJobs; + if (!userId) return; const notifications = new NotificationsService(); await Promise.all( @@ -317,8 +349,6 @@ async function enrichJobsWithProfileMatch( }), ), ); - - return matchedJobs; } /** @@ -339,6 +369,7 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => { const keywordsArray = getKeywordsArray(req.query); const pagination = parsePagination(req.query); const hasFilters = hasStructuredFilters(req.query); + const matchSort = getMatchSort(req.query); const matchTechnologies = await getCurrentUserMatchTechnologies(req); let ids: string[] = []; @@ -369,15 +400,30 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => { const legacy = await legacyResolveIds(keywordsArray); const legacyJobs = await cacheGetJobsByIds(legacy.ids); const filteredJobs = filterJobs(legacyJobs, req.query); - const { data: pageJobs, pagination: meta } = paginate( - filteredJobs, - pagination, - ); - const jobs = await enrichJobsWithProfileMatch( - req, - pageJobs, - matchTechnologies, - ); + const { data: pageJobs, pagination: meta } = matchSort + ? paginate( + sortJobsByMatch( + await enrichJobsWithProfileMatch( + req, + filteredJobs, + matchTechnologies, + { notifyHighMatches: false }, + ), + matchSort, + ), + pagination, + ) + : paginate(filteredJobs, pagination); + if (matchSort) { + await notifyHighMatchJobs(req, pageJobs as MatchedJob[]); + } + const jobs = matchSort + ? pageJobs + : await enrichJobsWithProfileMatch( + req, + pageJobs, + matchTechnologies, + ); return res.json({ total: meta.total, @@ -393,15 +439,30 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => { const indexedJobs = await cacheGetJobsByIds(ids); const filteredJobs = filterJobs(indexedJobs, req.query); - const { data: pageJobs, pagination: meta } = paginate( - filteredJobs, - pagination, - ); - const jobs = await enrichJobsWithProfileMatch( - req, - pageJobs, - matchTechnologies, - ); + const { data: pageJobs, pagination: meta } = matchSort + ? paginate( + sortJobsByMatch( + await enrichJobsWithProfileMatch( + req, + filteredJobs, + matchTechnologies, + { notifyHighMatches: false }, + ), + matchSort, + ), + pagination, + ) + : paginate(filteredJobs, pagination); + if (matchSort) { + await notifyHighMatchJobs(req, pageJobs as MatchedJob[]); + } + const jobs = matchSort + ? pageJobs + : await enrichJobsWithProfileMatch( + req, + pageJobs, + matchTechnologies, + ); return res.json({ total: meta.total, @@ -419,6 +480,33 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => { source = legacy.source; } + if (matchSort) { + const allJobs = await cacheGetJobsByIds(ids); + const matchedJobs = await enrichJobsWithProfileMatch( + req, + allJobs, + matchTechnologies, + { notifyHighMatches: false }, + ); + const sortedJobs = sortJobsByMatch(matchedJobs, matchSort); + const { data: jobs, pagination: meta } = paginate( + sortedJobs, + pagination, + ); + await notifyHighMatchJobs(req, jobs as MatchedJob[]); + + return res.json({ + total: meta.total, + page: meta.page, + limit: meta.limit, + totalPages: meta.totalPages, + hasNext: meta.hasNext, + hasPrev: meta.hasPrev, + jobs, + source: `${source}:match_sorted_${matchSort}`, + }); + } + const { data: pageIds, pagination: meta } = paginate(ids, pagination); const pageJobs = await cacheGetJobsByIds(pageIds); const jobs = await enrichJobsWithProfileMatch( diff --git a/backend/src/routes/notifications.routes.ts b/backend/src/routes/notifications.routes.ts index 17f86af..7dc81b8 100644 --- a/backend/src/routes/notifications.routes.ts +++ b/backend/src/routes/notifications.routes.ts @@ -3,6 +3,7 @@ import { validate } from "../middleware/validate"; import { NotificationsController } from "../modules/notifications/notifications.controller"; import { NotificationsService } from "../modules/notifications/notifications.service"; import { + clearNotificationsQuerySchema, listNotificationsQuerySchema, markAllNotificationsReadQuerySchema, } from "../modules/notifications/schemas/notifications.schemas"; @@ -31,4 +32,12 @@ router.patch("/:id/read", (req, res, next) => { controller.markRead(req, res).catch(next); }); +router.delete( + "/", + validate({ query: clearNotificationsQuerySchema }), + (req, res, next) => { + controller.clear(req, res).catch(next); + }, +); + export { router as notificationsRoutes }; diff --git a/backend/tests/integration/routes/notifications.routes.test.ts b/backend/tests/integration/routes/notifications.routes.test.ts index 1121d52..7af675c 100644 --- a/backend/tests/integration/routes/notifications.routes.test.ts +++ b/backend/tests/integration/routes/notifications.routes.test.ts @@ -6,6 +6,7 @@ const mockNotificationsService = vi.hoisted(() => ({ list: vi.fn(), markRead: vi.fn(), markAllRead: vi.fn(), + clear: vi.fn(), })); vi.mock("../../../src/modules/notifications/notifications.service", () => ({ @@ -60,6 +61,7 @@ describe("Integration - Notifications Routes", () => { readAt: new Date("2026-07-18T10:05:00.000Z").toISOString(), }); mockNotificationsService.markAllRead.mockResolvedValue({ updated: 3 }); + mockNotificationsService.clear.mockResolvedValue({ deleted: 4 }); app = createJobsApiApp(); }); @@ -108,6 +110,22 @@ describe("Integration - Notifications Routes", () => { ); }); + it("limpa as notificações de um canal", async () => { + const res = await request(app) + .delete(`${BASE}?channel=notification`) + .expect(200); + + expect(res.body).toEqual({ deleted: 4 }); + expect(mockNotificationsService.clear).toHaveBeenCalledWith( + "user_abc", + "notification", + ); + }); + + it("valida canal inválido ao limpar notificações", async () => { + await request(app).delete(`${BASE}?channel=email`).expect(400); + }); + it("retorna 404 quando a notificação não existe", async () => { mockNotificationsService.markRead.mockRejectedValueOnce( AppError.notFound("Notificação não encontrada"), diff --git a/backend/tests/unit/app.test.ts b/backend/tests/unit/app.test.ts index f714c15..0d005a0 100644 --- a/backend/tests/unit/app.test.ts +++ b/backend/tests/unit/app.test.ts @@ -492,6 +492,65 @@ describe("jobsApiApp", () => { ); }); + it("GET /jobs/search ordena por match globalmente antes da paginação", async () => { + mocks.parsePagination.mockReturnValue({ page: 1, limit: 2 }); + mocks.paginate.mockImplementationOnce((jobs: unknown[], params: any) => ({ + data: jobs.slice(0, params.limit), + pagination: { + total: jobs.length, + page: params.page, + limit: params.limit, + totalPages: Math.ceil(jobs.length / params.limit), + hasNext: true, + hasPrev: false, + }, + })); + mocks.cacheAbsoluteSMembers.mockResolvedValue(["low", "high", "middle"]); + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "low", + title: "Customer Success", + company: "ACME", + location: "Brasil", + }, + { + id: "high", + title: "React TypeScript Node Developer", + company: "Globex", + location: "Brasil", + }, + { + id: "middle", + title: "React Developer", + company: "Initech", + location: "Brasil", + }, + ]); + mocks.getUserById.mockResolvedValue({ + id: "test-user-id", + technologies: ["React", "TypeScript", "Node"], + technologyExperiencesEncrypted: null, + }); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ matchSort: "desc", page: "1", limit: "2" }) + .expect(200); + + expect(mocks.cacheGetJobsByIds).toHaveBeenCalledWith([ + "low", + "high", + "middle", + ]); + expect(res.body.jobs.map((job: any) => job.id)).toEqual([ + "high", + "middle", + ]); + expect(res.body.total).toBe(3); + expect(res.body.source).toBe("valkey_global_index:match_sorted_desc"); + }); + it("GET /jobs/search retorna paginação correta", async () => { mocks.parsePagination.mockReturnValue({ page: 2, limit: 10 }); mocks.paginate.mockReturnValue({ diff --git a/backend/tests/unit/modules/notifications/notifications.service.test.ts b/backend/tests/unit/modules/notifications/notifications.service.test.ts index 00a1b31..6649547 100644 --- a/backend/tests/unit/modules/notifications/notifications.service.test.ts +++ b/backend/tests/unit/modules/notifications/notifications.service.test.ts @@ -67,6 +67,12 @@ function makeUpdateBuilder(result: Partial[] = [notification]) return { builder: { set }, set, where, returning }; } +function makeDeleteBuilder(result: Partial[] = [notification]) { + const returning = vi.fn().mockResolvedValue(result); + const where = vi.fn().mockReturnValue({ returning }); + return { builder: { where }, where, returning }; +} + function makeTx() { return { query: { @@ -77,6 +83,7 @@ function makeTx() { select: vi.fn(), insert: vi.fn(), update: vi.fn(), + delete: vi.fn(), }; } @@ -171,6 +178,20 @@ describe("NotificationsService", () => { }); }); + it("limpa notificações de um canal", async () => { + const deleteQuery = makeDeleteBuilder([{ id: "n1" }, { id: "n2" }]); + tx.delete.mockReturnValue(deleteQuery.builder); + + await expect(service.clear("user-1", "notification")).resolves.toEqual({ + deleted: 2, + }); + + expect(tx.delete).toHaveBeenCalled(); + expect(deleteQuery.returning).toHaveBeenCalledWith({ + id: expect.anything(), + }); + }); + it("cria notificação para vaga salva", async () => { const insert = makeInsertBuilder(notification); tx.insert.mockReturnValue(insert.builder); diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index a7f7e6a..76a3664 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -19,6 +19,7 @@ import type { CareerChecklist, Job, JobStatus, + MatchSort, NewJob, SearchPreferences, TechnologyExperience, @@ -153,6 +154,7 @@ export default function NewDashboardPage() { const [continentFilter, setContinentFilter] = useState("Todos"); const [countryFilter, setCountryFilter] = useState("Todos"); + const [matchSort, setMatchSort] = useState("default"); const [selectedJobId, setSelectedJobId] = useState(null); const [isAddJobOpen, setIsAddJobOpen] = useState(false); const [toast, setToast] = useState(""); @@ -174,7 +176,6 @@ export default function NewDashboardPage() { recommendedPagination, isRefreshingJobs, refreshRecommendations, - changeRecommendationsPage, addTrackedJob, changeJobStatus, changeJobNotesLocally, @@ -296,7 +297,13 @@ export default function NewDashboardPage() { const handleRecommendationPageChange = async (page: number) => { try { - await changeRecommendationsPage(page); + const { keywords, filters } = buildRecommendationSearch(); + await refreshRecommendations( + keywords, + filters, + page, + recommendedPagination.limit, + ); } catch { // A camada de dados já apresentou o erro retornado pela API. } @@ -313,6 +320,7 @@ export default function NewDashboardPage() { ...(countryFilter !== "Todos" ? { country: countryFilter, location: countryFilter } : {}), + ...(matchSort !== "default" ? { matchSort } : {}), }; return { @@ -334,6 +342,7 @@ export default function NewDashboardPage() { continentFilter, filterLevel, filterType, + matchSort, refreshRecommendations, searchQuery, ]); @@ -361,6 +370,7 @@ export default function NewDashboardPage() { filterLevel, continentFilter, countryFilter, + matchSort, section, ]); @@ -399,6 +409,8 @@ export default function NewDashboardPage() { setContinentFilter={setContinentFilter} countryFilter={countryFilter} setCountryFilter={setCountryFilter} + matchSort={matchSort} + setMatchSort={setMatchSort} searchPreferences={searchPreferences} isSearching={isRefreshingJobs} pagination={recommendedPagination} diff --git a/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx b/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx index bd8e49a..640303f 100644 --- a/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx +++ b/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx @@ -77,7 +77,7 @@ export function DashboardTab({
-

Quadro Kanban de Candidaturas

+

Quadro Kanban de Vagas

Acompanhe e movimente o progresso de seus processos seletivos.

@@ -95,7 +95,7 @@ export function DashboardTab({
-

Análise de Candidaturas

+

Análise de Vagas

{funnelItems.map((item) => (
diff --git a/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx b/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx index a59f657..f4dbeb8 100644 --- a/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx +++ b/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx @@ -40,7 +40,7 @@ export function HomeTab({

- Candidaturas recentes + Vagas salvas recentes

{recentJobs.length > 0 ? ( @@ -68,7 +68,7 @@ export function HomeTab({

Nenhuma vaga salva ainda. Busque oportunidades e salve as - candidaturas que quiser acompanhar aqui. + vagas que quiser acompanhar aqui.

diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx index 20d46b5..6763abc 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx +++ b/frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx @@ -1,5 +1,6 @@ import { Search } from "lucide-react"; import { jobLevels, jobTypes } from "../../constants"; +import type { MatchSort } from "../../types"; import { continentOptions, countryOptions, @@ -18,6 +19,8 @@ interface JobFilterProps { setContinentFilter: (value: ContinentFilter) => void; countryFilter: CountryFilter; setCountryFilter: (value: CountryFilter) => void; + matchSort: MatchSort; + setMatchSort: (value: MatchSort) => void; } export function JobFilter({ @@ -31,9 +34,11 @@ export function JobFilter({ setContinentFilter, countryFilter, setCountryFilter, + matchSort, + setMatchSort, }: JobFilterProps) { return ( -
+
); } diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx index e92ae7f..b1f410a 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx +++ b/frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx @@ -85,11 +85,11 @@ export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx index 666d9d3..f085ce7 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx +++ b/frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx @@ -1,5 +1,5 @@ import { Search } from "lucide-react"; -import type { Job, JobStatus, SearchPreferences } from "../../types"; +import type { Job, JobStatus, MatchSort, SearchPreferences } from "../../types"; import { type ContinentFilter, type CountryFilter, @@ -19,6 +19,8 @@ interface JobTabProps { setContinentFilter: (value: ContinentFilter) => void; countryFilter: CountryFilter; setCountryFilter: (value: CountryFilter) => void; + matchSort: MatchSort; + setMatchSort: (value: MatchSort) => void; searchPreferences?: SearchPreferences; isSearching?: boolean; pagination?: { @@ -46,6 +48,8 @@ export function JobTab({ setContinentFilter, countryFilter, setCountryFilter, + matchSort, + setMatchSort, searchPreferences, isSearching = false, pagination, @@ -89,6 +93,8 @@ export function JobTab({ setContinentFilter={setContinentFilter} countryFilter={countryFilter} setCountryFilter={setCountryFilter} + matchSort={matchSort} + setMatchSort={setMatchSort} /> {searchPreferences?.remoteOnly && ( diff --git a/frontend/src/domains/new_dashboard/components/layout/Header.tsx b/frontend/src/domains/new_dashboard/components/layout/Header.tsx index 34c82a3..41c0e33 100644 --- a/frontend/src/domains/new_dashboard/components/layout/Header.tsx +++ b/frontend/src/domains/new_dashboard/components/layout/Header.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { initialMessages, initialNotifications } from "../../constants"; import { + clearDashboardNotifications, getDashboardNotificationFeed, markDashboardNotificationsRead, } from "../../infrastructure/notificationsApi"; @@ -50,7 +51,7 @@ export function Header({ const routeTitles: Array<{ path: string; title: string }> = [ { path: "/home", title: "Início" }, - { path: "/dashboard", title: "Métricas & Candidaturas" }, + { path: "/dashboard", title: "Métricas & Vagas" }, { path: "/vagas", title: "Vagas" }, { path: "/mentoria", title: "Mentoria" }, { path: "/perfil", title: "Meu Perfil Profissional" }, @@ -305,11 +306,10 @@ export function Header({ type="button" onClick={() => { setUnreadCount(0); - void markDashboardNotificationsRead("notification").catch( - () => { - // A leitura local já foi aplicada; a próxima carga reconcilia. - }, - ); + setMenuNotifications([]); + void clearDashboardNotifications("notification").catch(() => { + // A limpeza local já foi aplicada; a próxima carga reconcilia. + }); }} className="text-xs text-slate-400 hover:text-foreground" > diff --git a/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts b/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts index 4841485..b53d5f9 100644 --- a/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts +++ b/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts @@ -132,7 +132,7 @@ export function useDashboardJobs( const errors: string[] = []; if (savedResult.status === "rejected") { - errors.push("Não foi possível carregar suas candidaturas salvas."); + errors.push("Não foi possível carregar suas vagas salvas."); } if (recommendedResult.status === "rejected") { errors.push("Não foi possível atualizar as vagas recomendadas."); @@ -289,7 +289,7 @@ export function useDashboardJobs( return savedJob; } catch (error) { onError?.( - errorMessage(error, "Não foi possível atualizar a candidatura."), + errorMessage(error, "Não foi possível atualizar a vaga."), ); throw error; } diff --git a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts b/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts index d7ddc41..34d6364 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts +++ b/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts @@ -1,6 +1,13 @@ import { api } from "@/shared/lib/apiClient"; import { z } from "zod"; -import type { Job, JobLevel, JobStatus, JobType, NewJob } from "../types"; +import type { + Job, + JobLevel, + JobStatus, + JobType, + MatchSort, + NewJob, +} from "../types"; const ApiSearchJobSchema = z .object({ @@ -58,6 +65,7 @@ export type SearchJobFilters = { type?: string; model?: string; contract?: string; + matchSort?: Exclude; }; export type SearchJobsResult = { @@ -289,6 +297,7 @@ export async function searchDashboardJobs( ...(filters.type ? { type: filters.type } : {}), ...(filters.model ? { model: filters.model } : {}), ...(filters.contract ? { contract: filters.contract } : {}), + ...(filters.matchSort ? { matchSort: filters.matchSort } : {}), page, limit, }, diff --git a/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts b/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts index 8885c47..16bbbc7 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts +++ b/frontend/src/domains/new_dashboard/infrastructure/notificationsApi.ts @@ -113,3 +113,9 @@ export async function markDashboardNotificationsRead(channel: NotificationChanne params: { channel }, }); } + +export async function clearDashboardNotifications(channel: NotificationChannel) { + await api.delete("/notifications", { + params: { channel }, + }); +} diff --git a/frontend/src/domains/new_dashboard/types/job.ts b/frontend/src/domains/new_dashboard/types/job.ts index 824c4ca..9a5ccb0 100644 --- a/frontend/src/domains/new_dashboard/types/job.ts +++ b/frontend/src/domains/new_dashboard/types/job.ts @@ -52,3 +52,4 @@ export type JobType = z.infer; export type JobLevel = z.infer; export type Job = z.infer; export type NewJob = z.infer; +export type MatchSort = "default" | "desc" | "asc"; diff --git a/frontend/tests/unit/new_dashboard/api.test.ts b/frontend/tests/unit/new_dashboard/api.test.ts index 6373dc6..e80afc8 100644 --- a/frontend/tests/unit/new_dashboard/api.test.ts +++ b/frontend/tests/unit/new_dashboard/api.test.ts @@ -20,6 +20,7 @@ import { updateDashboardSavedJob, } from "@/domains/new_dashboard/infrastructure/dashboardJobsApi"; import { + clearDashboardNotifications, getDashboardNotificationFeed as getNotificationFeed, markDashboardNotificationsRead as markNotificationsRead, } from "@/domains/new_dashboard/infrastructure/notificationsApi"; @@ -74,6 +75,7 @@ describe("new_dashboard api adapters", () => { level: "Pleno", continent: "América do Sul", country: "Brasil", + matchSort: "desc", }, 3, 25, @@ -86,6 +88,7 @@ describe("new_dashboard api adapters", () => { level: "Pleno", continent: "América do Sul", country: "Brasil", + matchSort: "desc", page: 3, limit: 25, }, @@ -148,6 +151,7 @@ describe("new_dashboard api adapters", () => { const notifications = await getNotificationFeed("notification"); const messages = await getNotificationFeed("message"); await markNotificationsRead("notification"); + await clearDashboardNotifications("notification"); expect(notifications).toMatchObject({ unreadCount: 2, @@ -173,6 +177,9 @@ describe("new_dashboard api adapters", () => { null, { params: { channel: "notification" } }, ); + expect(apiMock.delete).toHaveBeenCalledWith("/notifications", { + params: { channel: "notification" }, + }); }); it("normaliza tipos, origens e datas do feed de notificações", async () => { diff --git a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx index 7498bcd..8af7f13 100644 --- a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx +++ b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx @@ -9,6 +9,7 @@ import { JobRow } from "@/domains/new_dashboard/components/jobs/JobRow"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { Modal } from "@/domains/new_dashboard/components/shared/Modal"; import { + clearDashboardNotifications, getDashboardNotificationFeed, markDashboardNotificationsRead, } from "@/domains/new_dashboard/infrastructure/notificationsApi"; @@ -33,6 +34,7 @@ vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ unreadCount: 0, }), markDashboardNotificationsRead: vi.fn().mockResolvedValue(undefined), + clearDashboardNotifications: vi.fn().mockResolvedValue(undefined), })); function renderWithRouter(ui: React.ReactElement) { @@ -75,12 +77,14 @@ describe("new_dashboard branch coverage", () => { localStorage.clear(); vi.mocked(getDashboardNotificationFeed).mockReset(); vi.mocked(markDashboardNotificationsRead).mockReset(); + vi.mocked(clearDashboardNotifications).mockReset(); vi.mocked(getDashboardNotificationFeed).mockResolvedValue({ messages: [], notifications: [], unreadCount: 0, } as never); vi.mocked(markDashboardNotificationsRead).mockResolvedValue(undefined); + vi.mocked(clearDashboardNotifications).mockResolvedValue(undefined); mockUseAuth.mockReturnValue({ user: null, logout: vi.fn(), @@ -173,7 +177,10 @@ describe("new_dashboard branch coverage", () => { expect(markDashboardNotificationsRead).toHaveBeenCalledWith("notification"); fireEvent.click(screen.getByText("Limpar")); - expect(markDashboardNotificationsRead).toHaveBeenCalledTimes(3); + expect(clearDashboardNotifications).toHaveBeenCalledWith("notification"); + expect( + screen.queryByText("React Developer tem 91% de compatibilidade."), + ).not.toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Menu do usuário")); fireEvent.click(screen.getByText("Sair da Conta")); diff --git a/frontend/tests/unit/new_dashboard/dashboard.layout.test.tsx b/frontend/tests/unit/new_dashboard/dashboard.layout.test.tsx index 563cc52..5b2c3f2 100644 --- a/frontend/tests/unit/new_dashboard/dashboard.layout.test.tsx +++ b/frontend/tests/unit/new_dashboard/dashboard.layout.test.tsx @@ -27,6 +27,7 @@ vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ unreadCount: 0, }), markDashboardNotificationsRead: vi.fn().mockResolvedValue(undefined), + clearDashboardNotifications: vi.fn().mockResolvedValue(undefined), })); function renderWithRouter(ui: React.ReactElement, pathname = "/dashboard") { @@ -152,7 +153,7 @@ describe("new_dashboard dashboard and layout components", () => { ); expect( - screen.getByRole("heading", { name: /métricas & candidaturas/i }), + screen.getByRole("heading", { name: /métricas & vagas/i }), ).toBeInTheDocument(); expect(screen.getByText(/maria clara/i)).toBeInTheDocument(); expect(screen.getByAltText(/avatar de maria clara/i)).toBeInTheDocument(); diff --git a/frontend/tests/unit/new_dashboard/home.profile.test.tsx b/frontend/tests/unit/new_dashboard/home.profile.test.tsx index d8ba823..3f2451c 100644 --- a/frontend/tests/unit/new_dashboard/home.profile.test.tsx +++ b/frontend/tests/unit/new_dashboard/home.profile.test.tsx @@ -63,7 +63,7 @@ describe("new_dashboard home and profile components", () => { localStorage.clear(); }); - it("renderiza o banner e a lista de candidaturas recentes no HomeTab", () => { + it("renderiza o banner e a lista de vagas salvas recentes no HomeTab", () => { render( { const setFilterLevel = vi.fn(); const setContinentFilter = vi.fn(); const setCountryFilter = vi.fn(); + const setMatchSort = vi.fn(); render( { setContinentFilter={setContinentFilter} countryFilter={"Todos" as CountryFilter} setCountryFilter={setCountryFilter} + matchSort="default" + setMatchSort={setMatchSort} />, ); @@ -87,12 +90,16 @@ describe("new_dashboard job components", () => { fireEvent.change(screen.getAllByRole("combobox")[3], { target: { value: "Portugal" }, }); + fireEvent.change(screen.getAllByRole("combobox")[4], { + target: { value: "desc" }, + }); expect(setSearchQuery).toHaveBeenCalledWith("react"); expect(setFilterType).toHaveBeenCalledWith("Remoto"); expect(setFilterLevel).toHaveBeenCalledWith("Sênior"); expect(setContinentFilter).toHaveBeenCalledWith("Europa"); expect(setCountryFilter).toHaveBeenCalledWith("Portugal"); + expect(setMatchSort).toHaveBeenCalledWith("desc"); }); it("renderiza a tabela vazia e paginação local e remota", () => { @@ -150,17 +157,17 @@ describe("new_dashboard job components", () => { expect(onPageChange).toHaveBeenCalledWith(3); }); - it("renderiza a linha de vaga e aciona detalhes/aplicar", () => { + it("renderiza a linha de vaga e aciona detalhes/salvar", () => { const onOpen = vi.fn(); const onStatusChange = vi.fn(); render(); fireEvent.click(screen.getAllByRole("button", { name: /detalhes/i })[0]); - fireEvent.click(screen.getByRole("button", { name: /aplicar/i })); + fireEvent.click(screen.getByRole("button", { name: /salvar/i })); expect(onOpen).toHaveBeenCalledWith(baseJob); - expect(onStatusChange).toHaveBeenCalledWith(baseJob.id, "applied"); + expect(onStatusChange).toHaveBeenCalledWith(baseJob.id, "saved"); }); it("mostra detalhes completos da vaga e atualiza status/notas", () => { @@ -284,6 +291,9 @@ describe("new_dashboard job components", () => { useState("Todos"); const [countryFilter, setCountryFilter] = useState("Todos"); + const [matchSort, setMatchSort] = useState<"default" | "desc" | "asc">( + "default", + ); return ( { setContinentFilter={setContinentFilter} countryFilter={countryFilter} setCountryFilter={setCountryFilter} + matchSort={matchSort} + setMatchSort={setMatchSort} searchPreferences={{ ...initialPreferences, remoteOnly: false }} onSearchJobs={onSearchJobs} onOpenJob={onOpenJob} diff --git a/frontend/tests/unit/new_dashboard/page.test.tsx b/frontend/tests/unit/new_dashboard/page.test.tsx index c22f8b4..f37f289 100644 --- a/frontend/tests/unit/new_dashboard/page.test.tsx +++ b/frontend/tests/unit/new_dashboard/page.test.tsx @@ -46,6 +46,7 @@ vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ unreadCount: 0, }), markDashboardNotificationsRead: vi.fn().mockResolvedValue(undefined), + clearDashboardNotifications: vi.fn().mockResolvedValue(undefined), })); function renderPage(pathname: string) { @@ -162,10 +163,10 @@ describe("NewDashboardPage", () => { screen.getByRole("heading", { name: /que bom te ver de volta/i }), ).toBeInTheDocument(); expect(screen.getByText(/frontend developer/i)).toBeInTheDocument(); - expect(screen.getByText(/candidaturas recentes/i)).toBeInTheDocument(); + expect(screen.getByText(/vagas salvas recentes/i)).toBeInTheDocument(); }); - it("renderiza o dashboard de candidaturas", () => { + it("renderiza o dashboard de vagas", () => { renderPage("/dashboard"); expect(screen.getByRole("heading", { name: /gerenciar vagas/i })).toBeInTheDocument(); @@ -220,8 +221,78 @@ describe("NewDashboardPage", () => { fireEvent.click(screen.getByRole("button", { name: /buscar vagas/i })); fireEvent.click(screen.getByRole("button", { name: /próxima página/i })); - expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalled(); - expect(dashboardJobsState.changeRecommendationsPage).toHaveBeenCalled(); + expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalledWith( + ["node.js"], + {}, + 2, + 50, + ); + expect(dashboardJobsState.changeRecommendationsPage).not.toHaveBeenCalled(); + }); + + it("mantém a ordenação por match ao trocar de página", () => { + renderPage("/vagas"); + + fireEvent.change(screen.getByDisplayValue("Match (padrão)"), { + target: { value: "desc" }, + }); + fireEvent.click(screen.getByRole("button", { name: /próxima página/i })); + + expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalledWith( + [], + { matchSort: "desc" }, + 2, + 50, + ); + }); + + it("mantém a ordem recebida do backend ao selecionar match", () => { + dashboardJobsState.recommendedJobs = [ + { + id: "job-low", + jobTitle: "Vaga menor match", + company: "ACME", + location: "Brasil", + salary: "A combinar", + type: "Remoto", + level: "Pleno", + matchScore: 45, + tags: ["React"], + posted: "Hoje", + status: "saved", + jobLink: "https://example.com/low", + source: "LinkedIn", + notes: "", + rawPayload: { matchSource: "backend_profile" }, + }, + { + id: "job-high", + jobTitle: "Vaga maior match", + company: "Globex", + location: "Brasil", + salary: "A combinar", + type: "Remoto", + level: "Pleno", + matchScore: 98, + tags: ["React"], + posted: "Hoje", + status: "saved", + jobLink: "https://example.com/high", + source: "Gupy", + notes: "", + rawPayload: { matchSource: "backend_profile" }, + }, + ]; + + renderPage("/vagas"); + + fireEvent.change(screen.getByDisplayValue("Match (padrão)"), { + target: { value: "desc" }, + }); + + const rows = screen.getAllByRole("row").slice(1); + expect(rows[0]).toHaveTextContent("Vaga menor match"); + expect(rows[1]).toHaveTextContent("Vaga maior match"); }); it("calcula o match das vagas com base nas tecnologias do perfil", () => { @@ -259,13 +330,20 @@ describe("NewDashboardPage", () => { it("envia filtros estruturados de localização para recomendações", () => { renderPage("/vagas"); - const [typeSelect, levelSelect, continentSelect, countrySelect] = + const [ + typeSelect, + levelSelect, + continentSelect, + countrySelect, + matchSortSelect, + ] = screen.getAllByRole("combobox"); fireEvent.change(typeSelect, { target: { value: "Híbrido" } }); fireEvent.change(levelSelect, { target: { value: "Sênior" } }); fireEvent.change(continentSelect, { target: { value: "América do Sul" } }); fireEvent.change(countrySelect, { target: { value: "Brasil" } }); + fireEvent.change(matchSortSelect, { target: { value: "desc" } }); fireEvent.click(screen.getByRole("button", { name: /buscar vagas/i })); expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalledWith( @@ -277,6 +355,7 @@ describe("NewDashboardPage", () => { continent: "América do Sul", country: "Brasil", location: "Brasil", + matchSort: "desc", }, 1, );