Skip to content

Commit eaeb87b

Browse files
committed
feat: implementa ordenação global por match e fluxo de vagas salvas
1 parent 8c7217e commit eaeb87b

26 files changed

Lines changed: 430 additions & 53 deletions

backend/src/modules/notifications/notifications.controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,13 @@ export class NotificationsController {
3636
);
3737
return res.json(result);
3838
}
39+
40+
async clear(req: Request, res: Response) {
41+
const userId = this.requireUserId(req);
42+
const result = await this.service.clear(
43+
userId,
44+
(req.query as { channel?: "notification" | "message" }).channel,
45+
);
46+
return res.json(result);
47+
}
3948
}

backend/src/modules/notifications/notifications.service.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ export class NotificationsService {
105105
return { updated: result.length };
106106
}
107107

108+
async clear(userId: string, channel?: "notification" | "message") {
109+
const result = await this.tx
110+
.delete(userNotifications)
111+
.where(
112+
and(
113+
eq(userNotifications.userId, userId),
114+
channel ? eq(userNotifications.channel, channel) : undefined,
115+
),
116+
)
117+
.returning({ id: userNotifications.id });
118+
119+
return { deleted: result.length };
120+
}
121+
108122
async createForSavedJob(userId: string, job: SavedJob) {
109123
const type = job.status === "applied" ? "job_applied" : "job_saved";
110124
const title =

backend/src/modules/notifications/schemas/notifications.schemas.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ export type ListNotificationsQuery = z.infer<
1616
export const markAllNotificationsReadQuerySchema = z.object({
1717
channel: z.enum(["notification", "message"]).optional(),
1818
});
19+
20+
export const clearNotificationsQuerySchema = z.object({
21+
channel: z.enum(["notification", "message"]).optional(),
22+
});

backend/src/routes/jobs.routes.ts

Lines changed: 109 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { logWarn } from "../logger";
1010
import {
1111
getUserMatchTechnologies,
1212
MatchableJob,
13+
MatchedJob,
1314
scoreJobWithTechnologies,
1415
} from "../modules/jobs/jobMatch.service";
1516
import { NotificationsService } from "../modules/notifications/notifications.service";
@@ -258,6 +259,29 @@ function getKeywordsArray(query: Request["query"]): string[] {
258259
.filter(Boolean);
259260
}
260261

262+
function getMatchSort(query: Request["query"]): "asc" | "desc" | null {
263+
const value = firstQueryValue(query.matchSort) || firstQueryValue(query.sort);
264+
return value === "asc" || value === "desc" ? value : null;
265+
}
266+
267+
function sortJobsByMatch<T>(
268+
jobs: T[],
269+
direction: "asc" | "desc",
270+
) {
271+
return [...jobs].sort((first, second) => {
272+
const firstJob = first as { matchScore?: number | null };
273+
const secondJob = second as { matchScore?: number | null };
274+
const firstScore =
275+
typeof firstJob.matchScore === "number" ? firstJob.matchScore : 0;
276+
const secondScore =
277+
typeof secondJob.matchScore === "number" ? secondJob.matchScore : 0;
278+
279+
return direction === "desc"
280+
? secondScore - firstScore
281+
: firstScore - secondScore;
282+
});
283+
}
284+
261285
async function legacyResolveIds(
262286
keywordsArray: string[],
263287
): Promise<{ ids: string[]; source: string }> {
@@ -294,14 +318,22 @@ async function enrichJobsWithProfileMatch(
294318
req: Request,
295319
jobs: unknown[],
296320
technologies: MatchTechnology[],
321+
options: { notifyHighMatches?: boolean } = {},
297322
) {
298323
if (technologies.length === 0) return jobs;
299324

300325
const matchedJobs = jobs.map((job) =>
301326
scoreJobWithTechnologies(job as MatchableJob, technologies),
302327
);
328+
if (options.notifyHighMatches === false) return matchedJobs;
329+
330+
await notifyHighMatchJobs(req, matchedJobs);
331+
return matchedJobs;
332+
}
333+
334+
async function notifyHighMatchJobs(req: Request, matchedJobs: MatchedJob[]) {
303335
const userId = req.session?.userId;
304-
if (!userId) return matchedJobs;
336+
if (!userId) return;
305337

306338
const notifications = new NotificationsService();
307339
await Promise.all(
@@ -317,8 +349,6 @@ async function enrichJobsWithProfileMatch(
317349
}),
318350
),
319351
);
320-
321-
return matchedJobs;
322352
}
323353

324354
/**
@@ -339,6 +369,7 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => {
339369
const keywordsArray = getKeywordsArray(req.query);
340370
const pagination = parsePagination(req.query);
341371
const hasFilters = hasStructuredFilters(req.query);
372+
const matchSort = getMatchSort(req.query);
342373
const matchTechnologies = await getCurrentUserMatchTechnologies(req);
343374

344375
let ids: string[] = [];
@@ -369,15 +400,30 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => {
369400
const legacy = await legacyResolveIds(keywordsArray);
370401
const legacyJobs = await cacheGetJobsByIds(legacy.ids);
371402
const filteredJobs = filterJobs(legacyJobs, req.query);
372-
const { data: pageJobs, pagination: meta } = paginate(
373-
filteredJobs,
374-
pagination,
375-
);
376-
const jobs = await enrichJobsWithProfileMatch(
377-
req,
378-
pageJobs,
379-
matchTechnologies,
380-
);
403+
const { data: pageJobs, pagination: meta } = matchSort
404+
? paginate(
405+
sortJobsByMatch(
406+
await enrichJobsWithProfileMatch(
407+
req,
408+
filteredJobs,
409+
matchTechnologies,
410+
{ notifyHighMatches: false },
411+
),
412+
matchSort,
413+
),
414+
pagination,
415+
)
416+
: paginate(filteredJobs, pagination);
417+
if (matchSort) {
418+
await notifyHighMatchJobs(req, pageJobs as MatchedJob[]);
419+
}
420+
const jobs = matchSort
421+
? pageJobs
422+
: await enrichJobsWithProfileMatch(
423+
req,
424+
pageJobs,
425+
matchTechnologies,
426+
);
381427

382428
return res.json({
383429
total: meta.total,
@@ -393,15 +439,30 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => {
393439

394440
const indexedJobs = await cacheGetJobsByIds(ids);
395441
const filteredJobs = filterJobs(indexedJobs, req.query);
396-
const { data: pageJobs, pagination: meta } = paginate(
397-
filteredJobs,
398-
pagination,
399-
);
400-
const jobs = await enrichJobsWithProfileMatch(
401-
req,
402-
pageJobs,
403-
matchTechnologies,
404-
);
442+
const { data: pageJobs, pagination: meta } = matchSort
443+
? paginate(
444+
sortJobsByMatch(
445+
await enrichJobsWithProfileMatch(
446+
req,
447+
filteredJobs,
448+
matchTechnologies,
449+
{ notifyHighMatches: false },
450+
),
451+
matchSort,
452+
),
453+
pagination,
454+
)
455+
: paginate(filteredJobs, pagination);
456+
if (matchSort) {
457+
await notifyHighMatchJobs(req, pageJobs as MatchedJob[]);
458+
}
459+
const jobs = matchSort
460+
? pageJobs
461+
: await enrichJobsWithProfileMatch(
462+
req,
463+
pageJobs,
464+
matchTechnologies,
465+
);
405466

406467
return res.json({
407468
total: meta.total,
@@ -419,6 +480,33 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => {
419480
source = legacy.source;
420481
}
421482

483+
if (matchSort) {
484+
const allJobs = await cacheGetJobsByIds(ids);
485+
const matchedJobs = await enrichJobsWithProfileMatch(
486+
req,
487+
allJobs,
488+
matchTechnologies,
489+
{ notifyHighMatches: false },
490+
);
491+
const sortedJobs = sortJobsByMatch(matchedJobs, matchSort);
492+
const { data: jobs, pagination: meta } = paginate(
493+
sortedJobs,
494+
pagination,
495+
);
496+
await notifyHighMatchJobs(req, jobs as MatchedJob[]);
497+
498+
return res.json({
499+
total: meta.total,
500+
page: meta.page,
501+
limit: meta.limit,
502+
totalPages: meta.totalPages,
503+
hasNext: meta.hasNext,
504+
hasPrev: meta.hasPrev,
505+
jobs,
506+
source: `${source}:match_sorted_${matchSort}`,
507+
});
508+
}
509+
422510
const { data: pageIds, pagination: meta } = paginate(ids, pagination);
423511
const pageJobs = await cacheGetJobsByIds(pageIds);
424512
const jobs = await enrichJobsWithProfileMatch(

backend/src/routes/notifications.routes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { validate } from "../middleware/validate";
33
import { NotificationsController } from "../modules/notifications/notifications.controller";
44
import { NotificationsService } from "../modules/notifications/notifications.service";
55
import {
6+
clearNotificationsQuerySchema,
67
listNotificationsQuerySchema,
78
markAllNotificationsReadQuerySchema,
89
} from "../modules/notifications/schemas/notifications.schemas";
@@ -31,4 +32,12 @@ router.patch("/:id/read", (req, res, next) => {
3132
controller.markRead(req, res).catch(next);
3233
});
3334

35+
router.delete(
36+
"/",
37+
validate({ query: clearNotificationsQuerySchema }),
38+
(req, res, next) => {
39+
controller.clear(req, res).catch(next);
40+
},
41+
);
42+
3443
export { router as notificationsRoutes };

backend/tests/integration/routes/notifications.routes.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const mockNotificationsService = vi.hoisted(() => ({
66
list: vi.fn(),
77
markRead: vi.fn(),
88
markAllRead: vi.fn(),
9+
clear: vi.fn(),
910
}));
1011

1112
vi.mock("../../../src/modules/notifications/notifications.service", () => ({
@@ -60,6 +61,7 @@ describe("Integration - Notifications Routes", () => {
6061
readAt: new Date("2026-07-18T10:05:00.000Z").toISOString(),
6162
});
6263
mockNotificationsService.markAllRead.mockResolvedValue({ updated: 3 });
64+
mockNotificationsService.clear.mockResolvedValue({ deleted: 4 });
6365

6466
app = createJobsApiApp();
6567
});
@@ -108,6 +110,22 @@ describe("Integration - Notifications Routes", () => {
108110
);
109111
});
110112

113+
it("limpa as notificações de um canal", async () => {
114+
const res = await request(app)
115+
.delete(`${BASE}?channel=notification`)
116+
.expect(200);
117+
118+
expect(res.body).toEqual({ deleted: 4 });
119+
expect(mockNotificationsService.clear).toHaveBeenCalledWith(
120+
"user_abc",
121+
"notification",
122+
);
123+
});
124+
125+
it("valida canal inválido ao limpar notificações", async () => {
126+
await request(app).delete(`${BASE}?channel=email`).expect(400);
127+
});
128+
111129
it("retorna 404 quando a notificação não existe", async () => {
112130
mockNotificationsService.markRead.mockRejectedValueOnce(
113131
AppError.notFound("Notificação não encontrada"),

backend/tests/unit/app.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,65 @@ describe("jobsApiApp", () => {
492492
);
493493
});
494494

495+
it("GET /jobs/search ordena por match globalmente antes da paginação", async () => {
496+
mocks.parsePagination.mockReturnValue({ page: 1, limit: 2 });
497+
mocks.paginate.mockImplementationOnce((jobs: unknown[], params: any) => ({
498+
data: jobs.slice(0, params.limit),
499+
pagination: {
500+
total: jobs.length,
501+
page: params.page,
502+
limit: params.limit,
503+
totalPages: Math.ceil(jobs.length / params.limit),
504+
hasNext: true,
505+
hasPrev: false,
506+
},
507+
}));
508+
mocks.cacheAbsoluteSMembers.mockResolvedValue(["low", "high", "middle"]);
509+
mocks.cacheGetJobsByIds.mockResolvedValue([
510+
{
511+
id: "low",
512+
title: "Customer Success",
513+
company: "ACME",
514+
location: "Brasil",
515+
},
516+
{
517+
id: "high",
518+
title: "React TypeScript Node Developer",
519+
company: "Globex",
520+
location: "Brasil",
521+
},
522+
{
523+
id: "middle",
524+
title: "React Developer",
525+
company: "Initech",
526+
location: "Brasil",
527+
},
528+
]);
529+
mocks.getUserById.mockResolvedValue({
530+
id: "test-user-id",
531+
technologies: ["React", "TypeScript", "Node"],
532+
technologyExperiencesEncrypted: null,
533+
});
534+
535+
const app = createJobsApiApp();
536+
const res = await request(app)
537+
.get("/jobs/search")
538+
.query({ matchSort: "desc", page: "1", limit: "2" })
539+
.expect(200);
540+
541+
expect(mocks.cacheGetJobsByIds).toHaveBeenCalledWith([
542+
"low",
543+
"high",
544+
"middle",
545+
]);
546+
expect(res.body.jobs.map((job: any) => job.id)).toEqual([
547+
"high",
548+
"middle",
549+
]);
550+
expect(res.body.total).toBe(3);
551+
expect(res.body.source).toBe("valkey_global_index:match_sorted_desc");
552+
});
553+
495554
it("GET /jobs/search retorna paginação correta", async () => {
496555
mocks.parsePagination.mockReturnValue({ page: 2, limit: 10 });
497556
mocks.paginate.mockReturnValue({

0 commit comments

Comments
 (0)