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
19 changes: 19 additions & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Abaixo
aberta
abertas
abordagem
aborta
Expand All @@ -23,6 +24,7 @@ acesso
acessos
achar
acidental
aciona
acompanhamento
acompanhar
acompanhe
Expand Down Expand Up @@ -106,6 +108,7 @@ anos
Anos
Anotações
anteriores
antiga
antigas
antigo
antigos
Expand Down Expand Up @@ -239,6 +242,7 @@ cadastrar
Cadastro
cadvisor
caixa
calcula
calculável
Camada
camadas
Expand Down Expand Up @@ -299,6 +303,7 @@ Ciclos
Círculo
cirúrgica
clampeia
classifica
clicar
clientes
cmdmeta
Expand Down Expand Up @@ -363,6 +368,7 @@ configuradas
configurado
configurados
Configurar
confirmada
conflita
conflitam
conflito
Expand Down Expand Up @@ -400,6 +406,7 @@ controla
Controle
Controles
conversão
conversar
Converte
convites
cooperado
Expand Down Expand Up @@ -441,6 +448,7 @@ currículo
curta
customuser
daqui
datas
datname
davecgh
debuglib
Expand Down Expand Up @@ -783,6 +791,7 @@ frança
França
freela
frequentemente
frequentes
fulano
Fulano
funciona
Expand Down Expand Up @@ -829,6 +838,8 @@ habilitado
habilitar
hashedpassword
híbrid
híbridas
Híbridas
híbrido
Hierarquia
hintrc
Expand Down Expand Up @@ -1131,6 +1142,7 @@ movimente
muda
mudam
mude
mudou
múltiplas
múltiplos
mundiais
Expand Down Expand Up @@ -1200,6 +1212,7 @@ oportunidade
oportunidades
optar
ordem
ordenação
Orfaos
órfãos
organicamente
Expand Down Expand Up @@ -1261,6 +1274,7 @@ perfeitamente
perfis
performático
pergunta
perguntas
periodo
permanecem
permanente
Expand Down Expand Up @@ -1290,6 +1304,7 @@ plataformas
plugar
pmezard
pôde
Podemos
poderem
poderia
poderosa
Expand All @@ -1314,6 +1329,7 @@ possivelmente
Possuam
possui
possuir
poucas
pouco
prática
práticas
Expand All @@ -1335,6 +1351,7 @@ prefixo
Prefixo
prefs
preparar
presenciais
presencial
presente
presentes
Expand Down Expand Up @@ -1428,6 +1445,7 @@ reconcilia
reconecta
reconhece
recorre
recrutador
Recrutadores
recrutamento
recupera
Expand Down Expand Up @@ -1557,6 +1575,7 @@ seguro
seguros
sejam
SELECIONADA
selecionar
seletivos
seletor
Selo
Expand Down
96 changes: 65 additions & 31 deletions backend/src/lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,35 @@ export type CacheJobIndexFilters = {
country?: string;
state?: string;
city?: string;
type?: string;
model?: string;
type?: string | string[];
model?: string | string[];
contract?: string;
};

export function cacheJobIndexKeys(filters: CacheJobIndexFilters): string[] {
const entries: Array<[string, string | undefined]> = [
function filterValues(value: string | string[] | undefined): string[] {
const values = Array.isArray(value) ? value : [value];

return values
.flatMap((item) => item?.split(",") ?? [])
.map((item) => item.trim())
.filter(Boolean);
}

function cacheJobIndexKey(kind: string, value: string): string {
const normalized =
kind === "level"
? normalizeLevelIndexValue(value)
: normalizeIndexValue(value);

if (!normalized || normalized === "todos" || normalized === "all") {
return "";
}

return `scraper:jobs:${kind}:${normalized}`;
}

function cacheJobIndexKeyGroups(filters: CacheJobIndexFilters): string[][] {
const entries: Array<[string, string | string[] | undefined]> = [
["level", filters.level],
["location", filters.location],
["continent", filters.continent],
Expand All @@ -215,52 +237,64 @@ export function cacheJobIndexKeys(filters: CacheJobIndexFilters): string[] {
];

return entries
.map(([kind, value]) => {
const normalized =
kind === "level"
? normalizeLevelIndexValue(value ?? "")
: normalizeIndexValue(value ?? "");
if (!normalized || normalized === "todos" || normalized === "all") {
return "";
}
return `scraper:jobs:${kind}:${normalized}`;
})
.filter(Boolean);
.map(([kind, value]) =>
filterValues(value)
.map((item) => cacheJobIndexKey(kind, item))
.filter(Boolean),
)
.filter((group) => group.length > 0);
}

export function cacheJobIndexKeys(filters: CacheJobIndexFilters): string[] {
return cacheJobIndexKeyGroups(filters).flatMap((group) => group);
}

export async function cacheSearchJobIds(
filters: CacheJobIndexFilters,
): Promise<string[]> {
const client = await getCache();
const keywordKeys = keywordSearchKeys(filters.keywords ?? []);
const filterKeys = cacheJobIndexKeys(filters);
const tempKeys: string[] = [];

if (keywordKeys.length === 0 && filterKeys.length === 0) {
return await client.sMembers("scraper:jobs:index");
}
const filterKeys = await Promise.all(
cacheJobIndexKeyGroups(filters).map(async (group) => {
if (group.length === 1) return group[0];

if (keywordKeys.length === 0) {
if (filterKeys.length === 1) return await client.sMembers(filterKeys[0]);
return (await client.sendCommand(["SINTER", ...filterKeys])) as string[];
}
const tempKey = `scraper:jobs:filter:${randomUUID()}`;
tempKeys.push(tempKey);
await client.sendCommand(["SUNIONSTORE", tempKey, ...group]);
await client.expire(tempKey, 30);
return tempKey;
}),
);

if (keywordKeys.length === 1) {
const keys = [keywordKeys[0], ...filterKeys];
if (keys.length === 1) return await client.sMembers(keys[0]);
return (await client.sendCommand(["SINTER", ...keys])) as string[];
}
try {
if (keywordKeys.length === 0 && filterKeys.length === 0) {
return await client.sMembers("scraper:jobs:index");
}

const tempKey = `scraper:jobs:search:${randomUUID()}`;
if (keywordKeys.length === 0) {
if (filterKeys.length === 1) return await client.sMembers(filterKeys[0]);
return (await client.sendCommand(["SINTER", ...filterKeys])) as string[];
}

if (keywordKeys.length === 1) {
const keys = [keywordKeys[0], ...filterKeys];
if (keys.length === 1) return await client.sMembers(keys[0]);
return (await client.sendCommand(["SINTER", ...keys])) as string[];
}

const tempKey = `scraper:jobs:search:${randomUUID()}`;
tempKeys.push(tempKey);

try {
await client.sendCommand(["SUNIONSTORE", tempKey, ...keywordKeys]);
await client.expire(tempKey, 30);

const keys = [tempKey, ...filterKeys];
if (keys.length === 1) return await client.sMembers(keys[0]);
return (await client.sendCommand(["SINTER", ...keys])) as string[];
} finally {
await client.del(tempKey);
await Promise.all(tempKeys.map((key) => client.del(key)));
}
}

Expand Down
30 changes: 23 additions & 7 deletions backend/src/routes/jobs.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ function firstQueryValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}

function queryValues(value: unknown): string[] {
const values = Array.isArray(value) ? value : [value];

return values
.flatMap((item) => (typeof item === "string" ? item.split(",") : []))
.map((item) => item.trim())
.filter(Boolean);
}

function normalizeComparable(value: string): string {
return value
.normalize("NFD")
Expand Down Expand Up @@ -209,16 +218,22 @@ function matchesLocationFilter(jobLocation: string, location: string): boolean {
return normalizedLocation.includes(location);
}

function getTypeFilters(query: Request["query"]): string[] {
const rawTypes = queryValues(query.model).length > 0
? queryValues(query.model)
: queryValues(query.type);

return [...new Set(rawTypes.map(normalizeComparable).filter(Boolean))];
}

function filterJobs(jobs: unknown[], query: Request["query"]): unknown[] {
const level = normalizeLevelFilter(firstQueryValue(query.level));
const location = normalizeComparable(
firstQueryValue(query.country) || firstQueryValue(query.location),
);
const type = normalizeComparable(
firstQueryValue(query.model) || firstQueryValue(query.type),
);
const types = getTypeFilters(query);

if (!level && !location && !type) return jobs;
if (!level && !location && types.length === 0) return jobs;

return jobs.filter((job) => {
const candidate = job as SearchJob;
Expand All @@ -227,7 +242,8 @@ function filterJobs(jobs: unknown[], query: Request["query"]): unknown[] {

const matchesLevel = !level || inferJobLevel(title) === level;
const matchesLocation = matchesLocationFilter(jobLocation, location);
const matchesType = !type || inferJobType(candidate) === type;
const matchesType =
types.length === 0 || types.includes(inferJobType(candidate));

return matchesLevel && matchesLocation && matchesType;
});
Expand Down Expand Up @@ -387,8 +403,8 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => {
country: firstQueryValue(req.query.country),
state: firstQueryValue(req.query.state),
city: firstQueryValue(req.query.city),
type: firstQueryValue(req.query.type),
model: firstQueryValue(req.query.model),
type: queryValues(req.query.type),
model: queryValues(req.query.model),
contract:
firstQueryValue(req.query.contract) ||
firstQueryValue(req.query.contractType) ||
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/unit/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,8 @@ describe("jobsApiApp", () => {
country: "",
state: "",
city: "",
type: "Remoto",
model: "",
type: ["Remoto"],
model: [],
contract: "",
});
expect(mocks.cacheSearchKeywords).not.toHaveBeenCalled();
Expand Down
27 changes: 27 additions & 0 deletions backend/tests/unit/libs/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,33 @@ describe("Valkey Cache Lib", () => {
);
expect(result).toEqual(["job_1", "job_2"]);
});

it("deve unir múltiplos modelos antes de intersectar com outros filtros", async () => {
mockClientInstance.sendCommand
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(["job_1", "job_2"]);

const result = await cacheSearchJobIds({
model: ["Remoto", "Híbrido"],
level: "Sênior",
});

expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(1, [
"SUNIONSTORE",
expect.stringMatching(/^scraper:jobs:filter:/),
"scraper:jobs:model:remoto",
"scraper:jobs:model:hibrido",
]);
expect(mockClientInstance.sendCommand).toHaveBeenNthCalledWith(2, [
"SINTER",
"scraper:jobs:level:senior",
expect.stringMatching(/^scraper:jobs:filter:/),
]);
expect(mockClientInstance.del).toHaveBeenCalledWith(
expect.stringMatching(/^scraper:jobs:filter:/),
);
expect(result).toEqual(["job_1", "job_2"]);
});
});

describe("cacheGetJobsByIds", () => {
Expand Down
Loading
Loading