Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
14 changes: 14 additions & 0 deletions backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
130 changes: 109 additions & 21 deletions backend/src/routes/jobs.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<T>(
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 }> {
Expand Down Expand Up @@ -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(
Expand All @@ -317,8 +349,6 @@ async function enrichJobsWithProfileMatch(
}),
),
);

return matchedJobs;
}

/**
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions backend/src/routes/notifications.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
18 changes: 18 additions & 0 deletions backend/tests/integration/routes/notifications.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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"),
Expand Down
59 changes: 59 additions & 0 deletions backend/tests/unit/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading