From 94a0200e94518a10b32c4e1b47f175ef9c8bf49e Mon Sep 17 00:00:00 2001 From: Benevanio Date: Mon, 28 Sep 2026 17:51:01 -0300 Subject: [PATCH] =?UTF-8?q?feat(PAV-115):=20corre=C3=A7=C3=A3o=20dos=20fil?= =?UTF-8?q?tros=20do=20frontend,=20melhorias=20nos=20match=20das=20vagas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cspell/custom-dictionary-workspace.txt | 19 + LOCAL_DEVELOPMENT.md | 19 +- backend/src/lib/cache.ts | 75 ++- .../modules/jobs/filters/jobSearch.filter.ts | 266 ++++++++--- .../jobs/parsers/jobSearchQuery.parser.ts | 5 + .../jobs/services/searchJobs.service.ts | 144 +++++- .../src/modules/jobs/types/jobSearch.types.ts | 2 + backend/src/scripts/seed.ts | 48 +- backend/src/scripts/seedCatalogJobs.ts | 442 ++++++++++++++++++ backend/tests/unit/app.test.ts | 31 +- backend/tests/unit/libs/cache.test.ts | 46 ++ .../modules/jobs/jobSearch.filter.test.ts | 150 ++++++ .../modules/jobs/searchJobs.service.test.ts | 281 +++++++++++ .../unit/scripts/seedCatalogJobs.test.ts | 73 +++ docker-compose.migrate.yml | 1 + .../domains/jobs/application/useJobsData.ts | 14 +- .../new_dashboard/NewDashboardPage.tsx | 17 +- .../new_dashboard/hooks/useDashboardJobs.ts | 28 +- .../infrastructure/dashboardJobsApi.ts | 60 ++- 19 files changed, 1587 insertions(+), 134 deletions(-) create mode 100644 backend/src/scripts/seedCatalogJobs.ts create mode 100644 backend/tests/unit/modules/jobs/jobSearch.filter.test.ts create mode 100644 backend/tests/unit/modules/jobs/searchJobs.service.test.ts create mode 100644 backend/tests/unit/scripts/seedCatalogJobs.test.ts diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index b04c7597..94477337 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -6086,3 +6086,22 @@ zupinnovation Zwirner zyngacareers zyngaearlycareers + +orfao +indice +indices +hidrata +hidratacao +scraper +srem +scard +estagio +sunionstore +compativel +perfil +vagas +paginacao +empresa +senioridade +modalidade +localizacao diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index c08405a2..01668771 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -444,12 +444,29 @@ Ele não deve ser utilizado para criar dados de produção. O seed cria: -* 1 usuário comum +* 1 usuário comum (com perfil técnico preenchido: React, TypeScript, Node.js, PostgreSQL, Docker e AWS) * 1 usuário administrador * 2 registros de preferências * 3 vagas salvas * 2 notas privadas * 2 eventos de alteração de candidatura +* 12 vagas de catálogo no Valkey, exibidas na aba `/vagas` + +## Vagas da aba `/vagas` + +A aba `/vagas` não lê do PostgreSQL: ela consome o catálogo que o scraper grava +no Valkey (`scraper:job:` mais os índices invertidos `scraper:jobs:*`). Para +que a tela tenha conteúdo sem rodar o scraper, o seed grava 12 vagas em +`backend/src/scripts/seedCatalogJobs.ts`, junto com os mesmos índices de +tecnologia, família, senioridade, nível, modelo, contrato e localidade. + +As vagas foram montadas para casar com o perfil técnico do usuário `dev` em +ordem decrescente de match — de 99% (todas as tecnologias do perfil) a 45% +(nenhuma tecnologia em comum). Com o filtro **Maior match** na aba, elas +aparecem exatamente nessa ordem. + +Esse trecho é ignorado com um aviso quando `VALKEY_URL` não está definida, e a +ordenação é coberta por `backend/tests/unit/scripts/seedCatalogJobs.test.ts`. --- diff --git a/backend/src/lib/cache.ts b/backend/src/lib/cache.ts index e3109dd2..b6acd0e3 100644 --- a/backend/src/lib/cache.ts +++ b/backend/src/lib/cache.ts @@ -141,14 +141,7 @@ export async function cacheAbsoluteSCard(absoluteKey: string): Promise { } } -/** - * Realiza uma busca cruzada (Interseção) entre múltiplos índices de palavras-chave no Valkey. - * Se apenas uma palavra-chave for enviada, retorna os membros dela diretamente. - * - * Normalização espelha o Go: - * "UX/UI Designer" → "ux ui designer" → chave: scraper:jobs:keyword:ux ui designer - * "UI" → "ui" → chave: scraper:jobs:keyword:ui - */ + export async function cacheSearchKeywords( keywords: string[], ): Promise { @@ -376,10 +369,24 @@ export async function cacheSearchJobIds( } } -export async function cacheGetJobsByIds(ids: string[]): Promise { +export type CacheJobsByIdsResult = { + /** Vagas encontradas no Valkey. */ + jobs: unknown[]; + /** IDs do indice sem documento `scraper:job:` (orfaos). */ + missingIds: string[]; +}; + +/** + * Hidrata os documentos das vagas a partir dos IDs do indice e informa quais + * IDs estao orfaos. O indice global e gravado sem TTL pelo scraper enquanto os + * documentos expiram em 9 dias (scraper-go/internal/pipeline/pipeline.go). + */ +export async function cacheGetJobsByIdsDetailed( + ids: string[], +): Promise { const client = await getCache(); - if (ids.length === 0) return []; + if (ids.length === 0) return { jobs: [], missingIds: [] }; const keys = ids.map((id) => `scraper:job:${id}`); let results: Array; @@ -390,21 +397,49 @@ export async function cacheGetJobsByIds(ids: string[]): Promise { throw error; } - const jobs = results - .filter((raw): raw is string => raw !== null) - .map((raw) => { - try { - return JSON.parse(raw); - } catch { - return null; - } - }) - .filter(Boolean); + const jobs: unknown[] = []; + const missingIds: string[] = []; + + results.forEach((raw, index) => { + if (raw === null) { + missingIds.push(ids[index]); + return; + } + + try { + jobs.push(JSON.parse(raw)); + } catch { + missingIds.push(ids[index]); + } + }); recordCacheOperation("mget_jobs", jobs.length > 0 ? "hit" : "miss"); + return { jobs, missingIds }; +} + +export async function cacheGetJobsByIds(ids: string[]): Promise { + const { jobs } = await cacheGetJobsByIdsDetailed(ids); return jobs; } +/** + * Remove IDs orfaos do indice global, espelhando o auto-reparo ja feito pelo + * scraper Go (jobstore.GetAll/GetSample). + */ +export async function cacheRemoveJobIndexIds(ids: string[]): Promise { + if (ids.length === 0) return 0; + + const client = await getCache(); + try { + const removed = await client.sRem("scraper:jobs:index", ids); + recordCacheOperation("srem_orphan_jobs", "ok"); + return removed; + } catch (error) { + recordCacheOperation("srem_orphan_jobs", "error"); + throw error; + } +} + async function cacheDeleteByPattern(pattern: string): Promise { const client = await getCache(); let cursor = "0"; diff --git a/backend/src/modules/jobs/filters/jobSearch.filter.ts b/backend/src/modules/jobs/filters/jobSearch.filter.ts index cc29d8d6..f972e227 100644 --- a/backend/src/modules/jobs/filters/jobSearch.filter.ts +++ b/backend/src/modules/jobs/filters/jobSearch.filter.ts @@ -1,6 +1,6 @@ import type { - ParsedJobSearchQuery, - SearchJob, + ParsedJobSearchQuery, + SearchJob, } from "../types/jobSearch.types"; export function normalizeComparable(value: string): string { @@ -101,63 +101,149 @@ function inferJobType(job: SearchJob): string { return "presencial"; } -function inferLocationCountry(location: string): string { - const normalized = normalizeComparable(location); - if (!normalized) return ""; +type LocationRule = { + country: string; + continent: string; + aliases: string[]; +}; - if ( - containsAny(normalized, [ - "estados unidos", - "united states", - "usa", - "eua", - "florida", - "miami", - "new york", - "california", - "texas", - "boston", - "seattle", - "chicago", - "atlanta", - "denver", - ]) - ) { - return "estados unidos"; + +const LOCATION_RULES: LocationRule[] = [ + { country: "brasil", continent: "america do sul", aliases: ["brasil", "brazil", "br"] }, + { + country: "estados unidos", + continent: "america do norte", + aliases: ["estados unidos", "united states", "usa", "us", "eua"], + }, + { country: "canada", continent: "america do norte", aliases: ["canada"] }, + { country: "mexico", continent: "america do norte", aliases: ["mexico"] }, + { country: "argentina", continent: "america do sul", aliases: ["argentina"] }, + { country: "chile", continent: "america do sul", aliases: ["chile"] }, + { country: "colombia", continent: "america do sul", aliases: ["colombia"] }, + { country: "portugal", continent: "europa", aliases: ["portugal"] }, + { country: "espanha", continent: "europa", aliases: ["spain", "espanha", "espana"] }, + { + country: "reino unido", + continent: "europa", + aliases: ["reino unido", "united kingdom", "uk", "england", "london"], + }, + { country: "franca", continent: "europa", aliases: ["france", "franca"] }, + { country: "alemanha", continent: "europa", aliases: ["germany", "alemanha", "deutschland"] }, + { + country: "paises baixos", + continent: "europa", + aliases: ["netherlands", "paises baixos", "holanda"], + }, + { country: "india", continent: "asia", aliases: ["india"] }, + { country: "singapura", continent: "asia", aliases: ["singapore", "singapura"] }, + { country: "australia", continent: "oceania", aliases: ["australia"] }, + { + country: "nova zelandia", + continent: "oceania", + aliases: ["new zealand", "nova zelandia"], + }, + { + country: "africa do sul", + continent: "africa", + aliases: ["south africa", "africa do sul"], + }, +]; + +const BRAZILIAN_STATES: Record = { + sp: ["sp", "sao paulo"], + rj: ["rj", "rio de janeiro"], + mg: ["mg", "minas gerais", "belo horizonte"], + pr: ["pr", "parana", "curitiba"], + sc: ["sc", "santa catarina", "florianopolis"], + rs: ["rs", "rio grande do sul", "porto alegre"], + ba: ["ba", "bahia", "salvador"], + pe: ["pe", "pernambuco", "recife"], + ce: ["ce", "ceara", "fortaleza"], + df: ["df", "distrito federal", "brasilia"], +}; + +const KNOWN_CITIES = [ + "sao paulo", + "rio de janeiro", + "belo horizonte", + "curitiba", + "joinville", + "florianopolis", + "porto alegre", + "salvador", + "recife", + "fortaleza", + "brasilia", + "lisboa", + "porto", + "madrid", + "barcelona", + "london", + "paris", + "berlin", + "amsterdam", + "toronto", + "vancouver", + "new york", + "san francisco", + "singapore", + "sydney", +]; + +export type JobLocationParts = { + country: string; + continent: string; + location: string; + state: string; + city: string; +}; + +export function inferJobLocationParts(location: string): JobLocationParts { + const text = normalizeComparable(location); + const parts: JobLocationParts = { + country: "", + continent: "", + location: "", + state: "", + city: "", + }; + + if (!text) return parts; + + for (const rule of LOCATION_RULES) { + if (containsAny(text, rule.aliases)) { + parts.country = rule.country; + parts.location = rule.country; + parts.continent = rule.continent; + break; + } } - if ( - containsAny(normalized, [ - "brasil", - "brazil", - "sao paulo", - "rio de janeiro", - "minas gerais", - "belo horizonte", - "parana", - "curitiba", - "santa catarina", - "joinville", - "rio grande do sul", - "porto alegre", - "pernambuco", - "recife", - "bahia", - "salvador", - "ceara", - "fortaleza", - "piaui", - "teresina", - ]) - ) { - return "brasil"; + if (!parts.continent && containsAny(text, ["remote", "remoto", "global", "worldwide"])) { + parts.continent = "global remoto"; + parts.location = "global remoto"; } - if (containsAny(normalized, ["portugal", "lisboa", "porto"])) { - return "portugal"; + for (const [state, aliases] of Object.entries(BRAZILIAN_STATES)) { + if (containsAny(text, aliases)) { + parts.state = state; + if (!parts.country) { + parts.country = "brasil"; + parts.location = "brasil"; + parts.continent = "america do sul"; + } + break; + } } - return ""; + const city = KNOWN_CITIES.find((candidate) => containsAny(text, [candidate])); + if (city) parts.city = city; + + return parts; +} + +function inferLocationCountry(location: string): string { + return inferJobLocationParts(location).country; } function matchesLocationFilter(jobLocation: string, location: string): boolean { @@ -170,6 +256,42 @@ function matchesLocationFilter(jobLocation: string, location: string): boolean { return normalizedLocation.includes(location); } +function inferJobContract(job: SearchJob): string { + const text = normalizeComparable( + [job.title, job.location, job.modality, job.description] + .filter(Boolean) + .join(" "), + ); + + if (containsAny(text, ["cooperado", "cooperativa"])) return "cooperado"; + if ( + containsAny(text, [ + "pj", + "pessoa juridica", + "contractor", + "freelance", + "freela", + ]) + ) { + return "pj"; + } + if (containsAny(text, ["clt", "full time", "efetivo", "permanent"])) { + return "clt"; + } + + return ""; +} + +function matchesLocationPart( + inferred: string, + jobLocation: string, + value: string, +): boolean { + if (!value) return true; + if (inferred) return inferred === value; + return normalizeComparable(jobLocation).includes(value); +} + export function filterJobs( jobs: unknown[], filters: ParsedJobSearchQuery, @@ -177,17 +299,27 @@ export function filterJobs( const level = normalizeLevelFilter(filters.level); const seniority = normalizeLevelFilter(filters.seniority); const location = normalizeComparable(filters.country || filters.location); + const continent = normalizeComparable(filters.continent); + const state = normalizeComparable(filters.state); + const city = normalizeComparable(filters.city); + const contract = normalizeComparable(filters.contract); const types = filters.type.map(normalizeComparable); const families = filters.family.map(normalizeComparable); const technologies = filters.technology.map(normalizeComparable); + const companies = filters.company.map(normalizeComparable); if ( !level && !seniority && !location && + !continent && + !state && + !city && + !contract && types.length === 0 && families.length === 0 && - technologies.length === 0 + technologies.length === 0 && + companies.length === 0 ) { return jobs; } @@ -196,6 +328,7 @@ export function filterJobs( const candidate = job as SearchJob; const title = candidate.title ?? ""; const jobLocation = candidate.location ?? ""; + const jobCompany = normalizeComparable(candidate.company ?? ""); const classification = candidate.classification; const classifiedFamilies = [ classification?.primaryFamily, @@ -215,7 +348,24 @@ export function filterJobs( !seniority || classifiedSeniority === seniority || inferJobLevel(title) === seniority; + const locationParts = inferJobLocationParts(jobLocation); const matchesLocation = matchesLocationFilter(jobLocation, location); + const matchesContinent = matchesLocationPart( + locationParts.continent, + jobLocation, + continent, + ); + const matchesState = matchesLocationPart( + locationParts.state, + jobLocation, + state, + ); + const matchesCity = matchesLocationPart( + locationParts.city, + jobLocation, + city, + ); + const matchesContract = !contract || inferJobContract(candidate) === contract; const matchesType = types.length === 0 || types.includes(inferJobType(candidate)); const matchesFamily = @@ -226,14 +376,22 @@ export function filterJobs( technologies.some((technology) => classifiedTechnologies.includes(technology), ); + const matchesCompany = + companies.length === 0 || + companies.some((company) => jobCompany.includes(company)); return ( matchesLevel && matchesSeniority && matchesLocation && + matchesContinent && + matchesState && + matchesCity && + matchesContract && matchesType && matchesFamily && - matchesTechnology + matchesTechnology && + matchesCompany ); }); } diff --git a/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts b/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts index e5efa063..ba711063 100644 --- a/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts +++ b/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts @@ -29,6 +29,7 @@ export function parseJobSearchQuery( keywords: queryValues(query.keywords), family: queryValues(query.family), technology: queryValues(query.technology), + company: queryValues(query.company), type, level: firstQueryValue(query.level), seniority: firstQueryValue(query.seniority), @@ -48,6 +49,10 @@ export function parseJobSearchQuery( }; } +export function hasPostOnlyFilters(filters: ParsedJobSearchQuery): boolean { + return filters.company.length > 0; +} + export function hasStructuredFilters(filters: ParsedJobSearchQuery): boolean { return Boolean( filters.level || diff --git a/backend/src/modules/jobs/services/searchJobs.service.ts b/backend/src/modules/jobs/services/searchJobs.service.ts index da910d23..c08cf3e8 100644 --- a/backend/src/modules/jobs/services/searchJobs.service.ts +++ b/backend/src/modules/jobs/services/searchJobs.service.ts @@ -1,16 +1,24 @@ import { - cacheAbsoluteSMembers, - cacheGetJobsByIds, - cacheSearchJobIds, - cacheSearchKeywords, + cacheAbsoluteSMembers, + cacheGetJobsByIds, + cacheGetJobsByIdsDetailed, + cacheRemoveJobIndexIds, + cacheSearchJobIds, + cacheSearchKeywords, } from "../../../lib/cache"; import { paginate, parsePagination } from "../../../lib/pagination"; +import { logWarn } from "../../../logger"; import { filterJobs, sortJobsByMatch } from "../filters/jobSearch.filter"; import { - hasStructuredFilters, - parseJobSearchQuery, + hasPostOnlyFilters, + hasStructuredFilters, + parseJobSearchQuery, } from "../parsers/jobSearchQuery.parser"; -import type { SearchJobsInput, SearchJobsResult } from "../types/jobSearch.types"; +import type { + MatchTechnology, + SearchJobsInput, + SearchJobsResult, +} from "../types/jobSearch.types"; import type { MatchableJob } from "./jobMatch.service"; import { JobProfileMatchService } from "./jobProfileMatch.service"; @@ -30,6 +38,101 @@ async function legacyResolveIds( }; } + +const MAX_HYDRATION_WINDOWS = 10; + +async function removeOrphanIds(missingIds: string[]): Promise { + if (missingIds.length === 0) return; + + try { + await cacheRemoveJobIndexIds(missingIds); + } catch (error) { + logWarn("Falha ao remover IDs órfãos do índice global de vagas", { + error: (error as Error).message, + orphans: missingIds.length, + }); + } +} + +async function hydrateIndexPage( + ids: string[], + { page, limit }: ReturnType, +): Promise<{ jobs: unknown[]; meta: ReturnType["pagination"] }> { + const jobs: unknown[] = []; + const missingIds: string[] = []; + let cursor = (page - 1) * limit; + let windows = 0; + + while ( + jobs.length < limit && + cursor < ids.length && + windows < MAX_HYDRATION_WINDOWS + ) { + const window = ids.slice(cursor, cursor + (limit - jobs.length)); + const hydrated = await cacheGetJobsByIdsDetailed(window); + + jobs.push(...hydrated.jobs); + missingIds.push(...hydrated.missingIds); + cursor += window.length; + windows += 1; + } + + await removeOrphanIds(missingIds); + + const total = Math.max(0, ids.length - missingIds.length); + const totalPages = Math.ceil(total / limit); + + return { + jobs, + meta: { + total, + page, + limit, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; +} + +async function orderIdsByProfileRelevance( + ids: string[], + technologies: MatchTechnology[], +): Promise<{ ids: string[]; matchedIds: number }> { + const names = technologies + .map((technology) => technology.name?.trim()) + .filter((name): name is string => Boolean(name)); + + if (names.length === 0 || ids.length === 0) { + return { ids, matchedIds: 0 }; + } + + let profileIds: string[]; + try { + profileIds = await cacheSearchKeywords(names); + } catch (error) { + logWarn("Não foi possível priorizar vagas pelo perfil do candidato", { + error: (error as Error).message, + }); + return { ids, matchedIds: 0 }; + } + + if (profileIds.length === 0) return { ids, matchedIds: 0 }; + + const relevant = new Set(profileIds); + const preferred: string[] = []; + const remaining: string[] = []; + + for (const id of ids) { + if (relevant.has(id)) preferred.push(id); + else remaining.push(id); + } + + if (preferred.length === 0) return { ids, matchedIds: 0 }; + + return { ids: [...preferred, ...remaining], matchedIds: preferred.length }; +} + function toSearchResult( jobs: unknown[], pagination: ReturnType["pagination"], @@ -109,6 +212,18 @@ export class SearchJobsService { ids = legacy.ids; source = legacy.source; + if (hasPostOnlyFilters(filters)) { + const legacyJobs = await cacheGetJobsByIds(ids); + return await this.paginateFilteredJobs( + filterJobs(legacyJobs, filters), + filters.matchSort, + pagination, + matchTechnologies, + input.userId, + `${source}:post_filter`, + ); + } + if (filters.matchSort) { const allJobs = await cacheGetJobsByIds(ids); const matchedJobs = await this.profileMatchService.enrich( @@ -128,15 +243,24 @@ export class SearchJobsService { return toSearchResult(jobs, meta, `${source}:match_sorted_${filters.matchSort}`); } - const { data: pageIds, pagination: meta } = paginate(ids, pagination); - const pageJobs = await cacheGetJobsByIds(pageIds); + const relevance = await orderIdsByProfileRelevance(ids, matchTechnologies); + const { jobs: pageJobs, meta } = await hydrateIndexPage( + relevance.ids, + pagination, + ); const jobs = await this.profileMatchService.enrich( input.userId, pageJobs as MatchableJob[], matchTechnologies, ); - return toSearchResult(jobs, meta, source); + if (relevance.matchedIds === 0) { + return toSearchResult(jobs, meta, source); + } return toSearchResult( + sortJobsByMatch(jobs, "desc"), + meta, + `${source}:profile_ranked`, + ); } private async searchWithPostFilterFallback( diff --git a/backend/src/modules/jobs/types/jobSearch.types.ts b/backend/src/modules/jobs/types/jobSearch.types.ts index 866ad0f8..5c7e3a0e 100644 --- a/backend/src/modules/jobs/types/jobSearch.types.ts +++ b/backend/src/modules/jobs/types/jobSearch.types.ts @@ -5,6 +5,7 @@ export type SearchJob = { id?: string; title?: string | null; jobTitle?: string | null; + company?: string | null; location?: string | null; modality?: string | null; description?: string | null; @@ -25,6 +26,7 @@ export type ParsedJobSearchQuery = { keywords: string[]; family: string[]; technology: string[]; + company: string[]; type: string[]; level: string; seniority: string; diff --git a/backend/src/scripts/seed.ts b/backend/src/scripts/seed.ts index dd7b72ef..98af8f7e 100644 --- a/backend/src/scripts/seed.ts +++ b/backend/src/scripts/seed.ts @@ -1,19 +1,24 @@ -import "dotenv/config"; import * as argon2 from "argon2"; +import "dotenv/config"; import { and, eq } from "drizzle-orm"; import { db, pool } from "../db/client"; import { - applicationEvents, - credentials, - savedJobs, - userPreferences, - users, + applicationEvents, + credentials, + savedJobs, + userPreferences, + users, } from "../db/schema"; import type { JobStatus } from "../db/schema/savedJobs"; import type { UserRole } from "../db/schema/users"; import { encryptText } from "../lib/security/encryption"; import { normalizeEmail } from "../lib/security/normalization"; import { generateSearchableHash } from "../lib/security/searchableHash"; +import { + SEED_DEV_TECHNOLOGIES, + seedCatalogJobs, + type SeedTechnologyExperience, +} from "./seedCatalogJobs"; const argonOptions = { type: argon2.argon2id, memoryCost: 65536, @@ -27,6 +32,8 @@ type SeedUserSpec = { email: string; password: string; role: UserRole; + level?: string; + technologies?: SeedTechnologyExperience[]; }; const SEED_USERS: SeedUserSpec[] = [ @@ -36,6 +43,8 @@ const SEED_USERS: SeedUserSpec[] = [ email: "dev@localhost.test", password: "Dev@123456", role: "user", + level: "Pleno", + technologies: SEED_DEV_TECHNOLOGIES, }, { username: "local.admin", @@ -92,6 +101,21 @@ const SEED_SAVED_JOBS: SeedSavedJobSpec[] = [ }, ]; + +function profileValues(spec: SeedUserSpec) { + return { + technologies: null, + technologiesEncrypted: encryptText( + JSON.stringify((spec.technologies ?? []).map((item) => item.name)), + ), + technologyExperiencesEncrypted: encryptText( + JSON.stringify(spec.technologies ?? []), + ), + level: null, + levelEncrypted: spec.level ? encryptText(spec.level) : null, + }; +} + async function upsertSeedUser(spec: SeedUserSpec): Promise { const normalizedEmail = normalizeEmail(spec.email); const emailHash = generateSearchableHash(normalizedEmail); @@ -101,6 +125,14 @@ async function upsertSeedUser(spec: SeedUserSpec): Promise { }); if (existingCredential) { + if (spec.technologies?.length) { + await db + .update(users) + .set({ ...profileValues(spec), updatedAt: new Date() }) + .where(eq(users.id, existingCredential.userId)); + console.log(`~ perfil técnico atualizado: ${spec.email}`); + } + console.log(`- usuário já existe, mantendo: ${spec.email}`); return existingCredential.userId; } @@ -117,6 +149,7 @@ async function upsertSeedUser(spec: SeedUserSpec): Promise { emailHash, emailVerified: true, role: spec.role, + ...profileValues(spec), }) .returning(); @@ -202,6 +235,7 @@ async function upsertApplicationEvent( console.log(`+ evento criado: ${fromStatus} -> ${toStatus}`); } + async function main() { console.log("Iniciando seed de desenvolvimento local..."); @@ -228,6 +262,8 @@ async function main() { "interviewing", ); + await seedCatalogJobs(); + console.log("Seed concluído."); } diff --git a/backend/src/scripts/seedCatalogJobs.ts b/backend/src/scripts/seedCatalogJobs.ts new file mode 100644 index 00000000..292a469a --- /dev/null +++ b/backend/src/scripts/seedCatalogJobs.ts @@ -0,0 +1,442 @@ +import { createClient } from "redis"; + +export type SeedTechnologyExperience = { + name: string; + years: number; +}; + +export const SEED_DEV_TECHNOLOGIES: SeedTechnologyExperience[] = [ + { name: "React", years: 5 }, + { name: "TypeScript", years: 5 }, + { name: "Node.js", years: 4 }, + { name: "PostgreSQL", years: 3 }, + { name: "Docker", years: 2 }, + { name: "AWS", years: 2 }, +]; + + +export type SeedCatalogJobSpec = { + slug: string; + title: string; + company: string; + location: string; + place: { continent: string; country: string; state?: string; city?: string }; + modality: string; + contract: string; + level: string; + seniority: string; + family: string; + technologies: string[]; + salary: string; + description: string; + postedAtDaysAgo: number; + + expectedMatch: number; +}; + + +export const SEED_CATALOG_JOBS: SeedCatalogJobSpec[] = [ + { + slug: "plataforma-fullstack-senior", + title: "Engenheiro(a) de Software Fullstack Sênior", + company: "Nimbus Labs", + location: "Remoto - Brasil", + place: { continent: "America do Sul", country: "Brasil" }, + modality: "Remoto", + contract: "PJ", + level: "Senior", + seniority: "Senior", + family: "Fullstack", + technologies: [ + "React", + "TypeScript", + "Node.js", + "PostgreSQL", + "Docker", + "AWS", + ], + salary: "R$ 16.000 - R$ 21.000", + description: + "Squad de plataforma de um produto multi-tenant. Front em React com TypeScript, serviços em Node.js, dados em PostgreSQL, empacotamento com Docker e infraestrutura na AWS.", + postedAtDaysAgo: 1, + expectedMatch: 99, + }, + { + slug: "produto-fullstack-pleno", + title: "Pessoa Desenvolvedora Fullstack Pleno", + company: "Harbor Sistemas", + location: "São Paulo, SP", + place: { + continent: "America do Sul", + country: "Brasil", + state: "SP", + city: "Sao Paulo", + }, + modality: "Híbrido", + contract: "CLT", + level: "Pleno", + seniority: "Pleno", + family: "Fullstack", + technologies: ["React", "TypeScript", "Node.js", "PostgreSQL", "Docker"], + salary: "R$ 12.000 - R$ 15.500", + description: + "Evolução de um produto de assinaturas: interfaces em React e TypeScript, APIs em Node.js, modelagem em PostgreSQL e ambientes locais com Docker.", + postedAtDaysAgo: 2, + expectedMatch: 96, + }, + { + slug: "web-fullstack-produto-digital", + title: "Desenvolvedor(a) Web Fullstack", + company: "Praia Digital", + location: "Remoto - Brasil", + place: { continent: "America do Sul", country: "Brasil" }, + modality: "Remoto", + contract: "PJ", + level: "Pleno", + seniority: "Pleno", + family: "Fullstack", + technologies: ["React", "TypeScript", "Node.js", "PostgreSQL"], + salary: "R$ 11.000 - R$ 14.000", + description: + "Produto de agendamentos com front em React, tipagem forte em TypeScript, backend em Node.js e persistência em PostgreSQL.", + postedAtDaysAgo: 3, + expectedMatch: 92, + }, + { + slug: "engenharia-produto-web", + title: "Engenheiro(a) de Produto Web", + company: "Volta Tecnologia", + location: "Belo Horizonte, MG", + place: { + continent: "America do Sul", + country: "Brasil", + state: "MG", + city: "Belo Horizonte", + }, + modality: "Híbrido", + contract: "CLT", + level: "Pleno", + seniority: "Pleno", + family: "Fullstack", + technologies: ["React", "TypeScript", "Node.js"], + salary: "R$ 10.500 - R$ 13.000", + description: + "Time de crescimento construindo experimentos em React e TypeScript, com serviços de apoio em Node.js e armazenamento em MySQL.", + postedAtDaysAgo: 4, + expectedMatch: 87, + }, + { + slug: "backend-servicos-distribuidos", + title: "Desenvolvedor(a) Backend de Serviços", + company: "Trilha Softworks", + location: "Remoto - Brasil", + place: { continent: "America do Sul", country: "Brasil" }, + modality: "Remoto", + contract: "PJ", + level: "Pleno", + seniority: "Pleno", + family: "Backend", + technologies: ["TypeScript", "Node.js", "Docker"], + salary: "R$ 10.000 - R$ 13.500", + description: + "Serviços em Node.js com TypeScript e NestJS, mensageria com RabbitMQ, MongoDB como base principal e entrega via Docker.", + postedAtDaysAgo: 5, + expectedMatch: 82, + }, + { + slug: "frontend-interfaces-pleno", + title: "Desenvolvedor(a) Frontend Pleno", + company: "Estúdio Farol", + location: "Remoto - Brasil", + place: { continent: "America do Sul", country: "Brasil" }, + modality: "Remoto", + contract: "PJ", + level: "Pleno", + seniority: "Pleno", + family: "Frontend", + technologies: ["React", "TypeScript"], + salary: "R$ 9.500 - R$ 12.000", + description: + "Construção de interfaces acessíveis em React e TypeScript, com Vite, Tailwind e testes em Vitest.", + postedAtDaysAgo: 6, + expectedMatch: 80, + }, + { + slug: "api-integracoes-backend", + title: "Pessoa Desenvolvedora Backend de Integrações", + company: "Ponte Pagamentos", + location: "Curitiba, PR", + place: { + continent: "America do Sul", + country: "Brasil", + state: "PR", + city: "Curitiba", + }, + modality: "Presencial", + contract: "CLT", + level: "Pleno", + seniority: "Pleno", + family: "Backend", + technologies: ["Node.js", "PostgreSQL"], + salary: "R$ 9.000 - R$ 11.500", + description: + "APIs de integração bancária escritas em Node.js com Express e JavaScript, usando PostgreSQL e filas internas.", + postedAtDaysAgo: 7, + expectedMatch: 75, + }, + { + slug: "infraestrutura-plataforma", + title: "Engenheiro(a) de Infraestrutura", + company: "Órbita Serviços", + location: "Remoto - Brasil", + place: { continent: "America do Sul", country: "Brasil" }, + modality: "Remoto", + contract: "PJ", + level: "Senior", + seniority: "Senior", + family: "Infraestrutura", + technologies: ["Docker", "AWS"], + salary: "R$ 14.000 - R$ 18.000", + description: + "Operação de clusters Kubernetes, imagens Docker, Terraform e contas AWS para os times de produto.", + postedAtDaysAgo: 8, + expectedMatch: 70, + }, + { + slug: "frontend-interfaces-junior", + title: "Desenvolvedor(a) Frontend Júnior", + company: "Casa Criativa", + location: "Remoto - Brasil", + place: { continent: "America do Sul", country: "Brasil" }, + modality: "Remoto", + contract: "CLT", + level: "Junior", + seniority: "Junior", + family: "Frontend", + technologies: ["React"], + salary: "R$ 4.500 - R$ 6.000", + description: + "Manutenção de páginas institucionais em React com JavaScript, Redux e CSS modular.", + postedAtDaysAgo: 9, + expectedMatch: 67, + }, + { + slug: "administracao-banco-dados", + title: "Administrador(a) de Banco de Dados", + company: "Serra Consultoria", + location: "Porto Alegre, RS", + place: { + continent: "America do Sul", + country: "Brasil", + state: "RS", + city: "Porto Alegre", + }, + modality: "Híbrido", + contract: "CLT", + level: "Pleno", + seniority: "Pleno", + family: "Dados", + technologies: ["PostgreSQL"], + salary: "R$ 9.000 - R$ 12.000", + description: + "Tuning, replicação e rotinas de backup em PostgreSQL sobre Linux, com monitoramento via Zabbix.", + postedAtDaysAgo: 10, + expectedMatch: 64, + }, + { + slug: "confiabilidade-plataforma", + title: "Analista de Confiabilidade (SRE)", + company: "Campo Aberto Tech", + location: "Remoto - Brasil", + place: { continent: "America do Sul", country: "Brasil" }, + modality: "Remoto", + contract: "PJ", + level: "Pleno", + seniority: "Pleno", + family: "Infraestrutura", + technologies: ["Docker"], + salary: "R$ 11.000 - R$ 14.000", + description: + "Observabilidade com Prometheus e Grafana, empacotamento em Docker e orquestração em Kubernetes na Google Cloud.", + postedAtDaysAgo: 11, + expectedMatch: 62, + }, + { + slug: "analise-dados-pleno", + title: "Analista de Dados Pleno", + company: "Bússola Analytics", + location: "Recife, PE", + place: { + continent: "America do Sul", + country: "Brasil", + state: "PE", + city: "Recife", + }, + modality: "Híbrido", + contract: "CLT", + level: "Pleno", + seniority: "Pleno", + family: "Dados", + technologies: ["Python", "Pandas", "Power BI"], + salary: "R$ 8.000 - R$ 10.500", + description: + "Modelagem analítica com Python e Pandas, construção de painéis em Power BI e consultas SQL em BigQuery.", + postedAtDaysAgo: 12, + expectedMatch: 45, + }, +]; + +function normalizeIndexValue(value: string): string { + return value + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/\//g, " ") + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function catalogJobId(spec: SeedCatalogJobSpec): string { + return `seed-${spec.slug}`; +} + +export function catalogJobDocument(spec: SeedCatalogJobSpec) { + const postedAt = new Date( + Date.now() - spec.postedAtDaysAgo * 24 * 60 * 60 * 1000, + ).toISOString(); + + return { + id: catalogJobId(spec), + title: spec.title, + company: spec.company, + location: spec.location, + url: `https://vagas.local/seed/${spec.slug}`, + salary: spec.salary, + modality: spec.modality, + description: spec.description, + postedAt, + source: "seed-local", + sources: ["seed-local"], + keyword: spec.technologies[0], + keywords: spec.technologies, + classification: { + primaryFamily: spec.family, + relatedFamilies: [], + technologies: spec.technologies, + seniority: spec.seniority, + inScope: true, + confidence: 1, + reasons: ["seed local"], + }, + }; +} + +/** + * Chaves do índice invertido que o scraper normalmente escreve + * (`IndexJobsInValkey`). Sem elas os filtros estruturados e a priorização por + * perfil não enxergam as vagas do seed. + */ +export function catalogJobIndexKeys(spec: SeedCatalogJobSpec): string[] { + const keys = new Set(); + + const add = (kind: string, value: string | undefined) => { + const normalized = normalizeIndexValue(value ?? ""); + if (normalized) keys.add(`scraper:jobs:${kind}:${normalized}`); + }; + + for (const technology of spec.technologies) { + add("technology", technology); + add("keyword", technology); + } + + add("family", spec.family); + add("keyword", spec.family); + add("seniority", spec.seniority); + add("level", spec.level); + add("model", spec.modality); + add("contract", spec.contract); + add("continent", spec.place.continent); + add("country", spec.place.country); + add("location", spec.place.country); + add("state", spec.place.state); + add("city", spec.place.city); + + return [...keys]; +} + +/** Mesmo TTL usado pelo scraper para documentos e índices invertidos. */ +const CATALOG_JOB_TTL_SECONDS = 9 * 24 * 60 * 60; + +/** + * Cliente dedicado ao seed. + * + * O cliente compartilhado da aplicação reconecta indefinidamente, o que trava + * o `db:seed` (e, por consequência, o container `migrate`) quando a + * `VALKEY_URL` aponta para um host inacessível. Aqui a conexão falha na + * primeira tentativa e o seed apenas avisa e segue. + */ +async function connectSeedCache(url: string) { + const client = createClient({ + url, + socket: { connectTimeout: 5000, reconnectStrategy: false }, + }); + + // Sem listener o erro de conexão vira exceção não tratada no processo. + client.on("error", () => {}); + + await client.connect(); + return client; +} + +export async function seedCatalogJobs(): Promise { + const url = process.env.VALKEY_URL; + + if (!url) { + console.log( + "! VALKEY_URL nao definida: pulando o seed das vagas da aba /vagas.", + ); + return; + } + + let client: Awaited>; + try { + client = await connectSeedCache(url); + } catch (error) { + // As vagas do catálogo são opcionais: nunca devem quebrar migrate + seed. + console.log( + `! Valkey indisponível em ${url} (${(error as Error).message}): pulando o seed das vagas da aba /vagas.`, + ); + return; + } + + try { + for (const spec of SEED_CATALOG_JOBS) { + const id = catalogJobId(spec); + + await client.set( + `scraper:job:${id}`, + JSON.stringify(catalogJobDocument(spec)), + { EX: CATALOG_JOB_TTL_SECONDS }, + ); + await client.sAdd("scraper:jobs:index", id); + + for (const key of catalogJobIndexKeys(spec)) { + await client.sAdd(key, id); + await client.expire(key, CATALOG_JOB_TTL_SECONDS); + } + + console.log( + `+ vaga do catálogo indexada: ${spec.title} (match esperado=${spec.expectedMatch}%)`, + ); + } + } finally { + await client.quit().catch(() => client.destroy()); + } + + const maior = SEED_CATALOG_JOBS[0].expectedMatch; + const menor = SEED_CATALOG_JOBS[SEED_CATALOG_JOBS.length - 1].expectedMatch; + console.log( + `Catálogo do seed: ${SEED_CATALOG_JOBS.length} vagas, do maior (${maior}%) ao menor match (${menor}%).`, + ); +} diff --git a/backend/tests/unit/app.test.ts b/backend/tests/unit/app.test.ts index e385b7b5..9b196797 100644 --- a/backend/tests/unit/app.test.ts +++ b/backend/tests/unit/app.test.ts @@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({ cacheSearchJobIds: vi.fn(), cacheAbsoluteSMembers: vi.fn(), cacheGetJobsByIds: vi.fn(), + cacheGetJobsByIdsDetailed: vi.fn(), + cacheRemoveJobIndexIds: vi.fn(), getCache: vi.fn(), publish: vi.fn(), logWarn: vi.fn(), @@ -26,6 +28,8 @@ vi.mock("../../src/lib/cache.js", () => ({ cacheSearchJobIds: mocks.cacheSearchJobIds, cacheAbsoluteSMembers: mocks.cacheAbsoluteSMembers, cacheGetJobsByIds: mocks.cacheGetJobsByIds, + cacheGetJobsByIdsDetailed: mocks.cacheGetJobsByIdsDetailed, + cacheRemoveJobIndexIds: mocks.cacheRemoveJobIndexIds, getCache: mocks.getCache, })); @@ -135,6 +139,11 @@ describe("jobsApiApp", () => { { id: "id-1", title: "Dev Java", company: "ACME" }, { id: "id-2", title: "Dev Node", company: "Globo" }, ]); + mocks.cacheGetJobsByIdsDetailed.mockImplementation(async (ids: string[]) => ({ + jobs: await mocks.cacheGetJobsByIds(ids), + missingIds: [], + })); + mocks.cacheRemoveJobIndexIds.mockResolvedValue(0); mocks.dbOrderBy.mockResolvedValue([ { keyword: "Java", source: "user" }, { keyword: "Node.js", source: "user" }, @@ -633,21 +642,12 @@ describe("jobsApiApp", () => { }); it("GET /jobs/search retorna paginação correta", async () => { + const ids = Array.from({ length: 15 }, (_, index) => `id-${index + 1}`); mocks.parsePagination.mockReturnValue({ page: 2, limit: 10 }); - mocks.paginate.mockReturnValue({ - data: ["id-1"], - pagination: { - total: 15, - page: 2, - limit: 10, - totalPages: 2, - hasNext: false, - hasPrev: true, - }, - }); - mocks.cacheGetJobsByIds.mockResolvedValue([ - { id: "id-1", title: "Dev", company: "ACME" }, - ]); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIds.mockImplementation(async (pageIds: string[]) => + pageIds.map((id) => ({ id, title: "Dev", company: "ACME" })), + ); const app = createJobsApiApp(); const res = await request(app) @@ -658,8 +658,11 @@ describe("jobsApiApp", () => { expect(res.body.page).toBe(2); expect(res.body.limit).toBe(10); expect(res.body.total).toBe(15); + expect(res.body.totalPages).toBe(2); expect(res.body.hasPrev).toBe(true); expect(res.body.hasNext).toBe(false); + expect(res.body.jobs).toHaveLength(5); + expect(res.body.jobs[0].id).toBe("id-11"); }); it("GET /jobs/search retorna 500 quando cacheSearchKeywords falha", async () => { diff --git a/backend/tests/unit/libs/cache.test.ts b/backend/tests/unit/libs/cache.test.ts index abcbb46d..5d6c1568 100644 --- a/backend/tests/unit/libs/cache.test.ts +++ b/backend/tests/unit/libs/cache.test.ts @@ -6,6 +6,8 @@ import { cacheDel, cacheGet, cacheGetJobsByIds, + cacheGetJobsByIdsDetailed, + cacheRemoveJobIndexIds, cacheJobIndexKeys, cacheClearJobs, cacheSearchJobIds, @@ -45,6 +47,7 @@ vi.mock("redis", () => { set: vi.fn(), del: vi.fn(), sMembers: vi.fn(), + sRem: vi.fn(), sCard: vi.fn(), sUnion: vi.fn(), sendCommand: vi.fn(), @@ -410,6 +413,49 @@ describe("Valkey Cache Lib", () => { }); }); + describe("cacheGetJobsByIdsDetailed", () => { + it("deve reportar os IDs do índice que não possuem documento", async () => { + mockClientInstance.mGet.mockResolvedValue([ + JSON.stringify({ title: "Go Dev" }), + null, + "invalid-json-data", + ]); + + const result = await cacheGetJobsByIdsDetailed(["1", "2", "3"]); + + expect(result.jobs).toEqual([{ title: "Go Dev" }]); + expect(result.missingIds).toEqual(["2", "3"]); + }); + + it("deve retornar vazio sem consultar o Valkey quando não há IDs", async () => { + const result = await cacheGetJobsByIdsDetailed([]); + + expect(result).toEqual({ jobs: [], missingIds: [] }); + expect(mockClientInstance.mGet).not.toHaveBeenCalled(); + }); + }); + + describe("cacheRemoveJobIndexIds", () => { + it("deve remover os IDs órfãos do índice global", async () => { + mockClientInstance.sRem.mockResolvedValue(2); + + const removed = await cacheRemoveJobIndexIds(["1", "2"]); + + expect(mockClientInstance.sRem).toHaveBeenCalledWith( + "scraper:jobs:index", + ["1", "2"], + ); + expect(removed).toBe(2); + }); + + it("não deve chamar o Valkey quando a lista está vazia", async () => { + const removed = await cacheRemoveJobIndexIds([]); + + expect(removed).toBe(0); + expect(mockClientInstance.sRem).not.toHaveBeenCalled(); + }); + }); + describe("cacheClearJobs", () => { it("deve remover payloads e índices de vagas por SCAN em lotes", async () => { mockClientInstance.sendCommand diff --git a/backend/tests/unit/modules/jobs/jobSearch.filter.test.ts b/backend/tests/unit/modules/jobs/jobSearch.filter.test.ts new file mode 100644 index 00000000..6ce6d2b0 --- /dev/null +++ b/backend/tests/unit/modules/jobs/jobSearch.filter.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + filterJobs, + inferJobLocationParts, + sortJobsByMatch, +} from "../../../../src/modules/jobs/filters/jobSearch.filter"; +import { parseJobSearchQuery } from "../../../../src/modules/jobs/parsers/jobSearchQuery.parser"; + +function filters(query: Record) { + return parseJobSearchQuery(query as never); +} + +const JOBS = [ + { + id: "1", + title: "Desenvolvedor Go Sênior", + company: "ACME Tecnologia", + location: "Joinville, Santa Catarina - Brasil", + description: "Vaga presencial CLT", + classification: { + primaryFamily: "Backend", + technologies: ["Go", "PostgreSQL"], + seniority: "Sênior", + }, + }, + { + id: "2", + title: "Desenvolvedora Frontend Júnior", + company: "Globo", + location: "Remoto - Worldwide", + description: "Contrato PJ, home office", + classification: { + primaryFamily: "Frontend", + technologies: ["React"], + seniority: "Júnior", + }, + }, + { + id: "3", + title: "Engenheiro de Dados Pleno", + company: "Acme Labs", + location: "Toronto, Canadá", + description: "Hybrid role, full time", + classification: { + primaryFamily: "Data", + technologies: ["Python"], + seniority: "Pleno", + }, + }, +]; + +describe("inferJobLocationParts", () => { + it("infere país, continente, estado e cidade como o scraper Go", () => { + expect(inferJobLocationParts("Joinville, Santa Catarina - Brasil")).toEqual({ + country: "brasil", + continent: "america do sul", + location: "brasil", + state: "sc", + city: "joinville", + }); + }); + + it("classifica vagas sem país como 'global remoto'", () => { + const parts = inferJobLocationParts("Remoto - Worldwide"); + expect(parts.continent).toBe("global remoto"); + expect(parts.country).toBe(""); + }); + + it("reconhece países fora de Brasil/EUA/Portugal", () => { + expect(inferJobLocationParts("Toronto, Canadá").country).toBe("canada"); + expect(inferJobLocationParts("Toronto, Canadá").continent).toBe( + "america do norte", + ); + }); +}); + +describe("filterJobs", () => { + it("devolve tudo quando não há filtros", () => { + expect(filterJobs(JOBS, filters({}))).toHaveLength(3); + }); + + it("filtra por empresa ignorando acento e caixa", () => { + const result = filterJobs(JOBS, filters({ company: "acme" })); + expect(result.map((job) => (job as { id: string }).id)).toEqual(["1", "3"]); + }); + + it("filtra por contrato espelhando a inferência do scraper", () => { + expect(filterJobs(JOBS, filters({ contract: "pj" }))).toHaveLength(1); + // "CLT" (vaga 1) e "full time" (vaga 3) são tratados como CLT, igual ao Go. + expect(filterJobs(JOBS, filters({ contract: "clt" }))).toHaveLength(2); + }); + + it("filtra por continente, estado e cidade", () => { + expect( + filterJobs(JOBS, filters({ continent: "Global / Remoto" })), + ).toHaveLength(1); + expect(filterJobs(JOBS, filters({ state: "SC" }))).toHaveLength(1); + expect(filterJobs(JOBS, filters({ city: "Joinville" }))).toHaveLength(1); + }); + + it("filtra por país usando a mesma tabela do índice", () => { + expect(filterJobs(JOBS, filters({ country: "Canadá" }))).toHaveLength(1); + expect(filterJobs(JOBS, filters({ country: "Brasil" }))).toHaveLength(1); + }); + + it("filtra por tecnologia e família da classificação", () => { + expect(filterJobs(JOBS, filters({ technology: "Go" }))).toHaveLength(1); + expect(filterJobs(JOBS, filters({ family: "Frontend" }))).toHaveLength(1); + }); + + it("filtra por senioridade e modalidade", () => { + expect(filterJobs(JOBS, filters({ seniority: "Sênior" }))).toHaveLength(1); + expect(filterJobs(JOBS, filters({ type: "remoto" }))).toHaveLength(1); + expect(filterJobs(JOBS, filters({ model: "hibrido" }))).toHaveLength(1); + }); + + it("combina múltiplos filtros com AND", () => { + expect( + filterJobs(JOBS, filters({ company: "acme", country: "Brasil" })), + ).toHaveLength(1); + expect( + filterJobs(JOBS, filters({ company: "acme", contract: "pj" })), + ).toHaveLength(0); + }); + + it("ignora filtros vazios ou nulos", () => { + expect( + filterJobs( + JOBS, + filters({ company: "", country: " ", technology: ",", level: "" }), + ), + ).toHaveLength(3); + }); +}); + +describe("sortJobsByMatch", () => { + it("ordena por matchScore tratando ausência como zero", () => { + const jobs = [{ id: "a" }, { id: "b", matchScore: 90 }, { id: "c", matchScore: 50 }]; + expect(sortJobsByMatch(jobs, "desc").map((job) => job.id)).toEqual([ + "b", + "c", + "a", + ]); + expect(sortJobsByMatch(jobs, "asc").map((job) => job.id)).toEqual([ + "a", + "c", + "b", + ]); + }); +}); diff --git a/backend/tests/unit/modules/jobs/searchJobs.service.test.ts b/backend/tests/unit/modules/jobs/searchJobs.service.test.ts new file mode 100644 index 00000000..f9f50da4 --- /dev/null +++ b/backend/tests/unit/modules/jobs/searchJobs.service.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + cacheAbsoluteSMembers: vi.fn(), + cacheGetJobsByIds: vi.fn(), + cacheGetJobsByIdsDetailed: vi.fn(), + cacheRemoveJobIndexIds: vi.fn(), + cacheSearchJobIds: vi.fn(), + cacheSearchKeywords: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/lib/cache.js", () => ({ + cacheAbsoluteSMembers: mocks.cacheAbsoluteSMembers, + cacheGetJobsByIds: mocks.cacheGetJobsByIds, + cacheGetJobsByIdsDetailed: mocks.cacheGetJobsByIdsDetailed, + cacheRemoveJobIndexIds: mocks.cacheRemoveJobIndexIds, + cacheSearchJobIds: mocks.cacheSearchJobIds, + cacheSearchKeywords: mocks.cacheSearchKeywords, +})); + +vi.mock("../../../../src/logger.js", () => ({ + logWarn: mocks.logWarn, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const profileMocks = vi.hoisted(() => ({ + technologies: [] as Array<{ name: string; years: number }>, +})); + +vi.mock( + "../../../../src/modules/jobs/services/jobProfileMatch.service.js", + async () => { + const { scoreJobWithTechnologies } = await import( + "../../../../src/modules/jobs/services/jobMatch.service.js" + ); + + return { + JobProfileMatchService: class { + async getUserTechnologies() { + return profileMocks.technologies; + } + async enrich(_userId: unknown, jobs: any[], technologies: any[]) { + if (!technologies || technologies.length === 0) return jobs; + return jobs.map((job) => + scoreJobWithTechnologies(job, technologies), + ); + } + }, + }; + }, +); + +import { SearchJobsService } from "../../../../src/modules/jobs/services/searchJobs.service.js"; + +const INDEX_KEY = "scraper:jobs:index"; + +function makeIds(count: number, prefix = "id"): string[] { + return Array.from({ length: count }, (_, index) => `${prefix}-${index + 1}`); +} + +function job(id: string) { + return { id, title: `Vaga ${id}`, company: "ACME" }; +} + +function hydrateFrom(liveIds: Set) { + return async (ids: string[]) => ({ + jobs: ids.filter((id) => liveIds.has(id)).map(job), + missingIds: ids.filter((id) => !liveIds.has(id)), + }); +} + +describe("SearchJobsService — índice global do Valkey", () => { + let service: SearchJobsService; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheRemoveJobIndexIds.mockResolvedValue(0); + mocks.cacheSearchJobIds.mockResolvedValue([]); + mocks.cacheSearchKeywords.mockResolvedValue([]); + profileMocks.technologies = []; + service = new SearchJobsService(); + }); + + it("retorna a primeira página preenchida quando todos os IDs têm documento", async () => { + const ids = makeIds(120); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation( + hydrateFrom(new Set(ids)), + ); + + const result = await service.execute({ query: { page: "1", limit: "50" } }); + + expect(mocks.cacheAbsoluteSMembers).toHaveBeenCalledWith(INDEX_KEY); + expect(result.source).toBe("valkey_global_index"); + expect(result.total).toBe(120); + expect(result.page).toBe(1); + expect(result.limit).toBe(50); + expect(result.totalPages).toBe(3); + expect(result.hasNext).toBe(true); + expect(result.hasPrev).toBe(false); + expect(result.jobs).toHaveLength(50); + expect(result.jobs[0]).toEqual(job("id-1")); + expect(mocks.cacheRemoveJobIndexIds).not.toHaveBeenCalled(); + }); + + it("preenche a página avançando no índice quando a primeira fatia só tem IDs órfãos", async () => { + const ids = makeIds(200); + // Os 50 primeiros IDs do índice estão órfãos (documentos expirados). + const liveIds = new Set(ids.slice(50)); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation(hydrateFrom(liveIds)); + + const result = await service.execute({ query: { page: "1", limit: "50" } }); + + expect(result.jobs).toHaveLength(50); + expect(result.jobs[0]).toEqual(job("id-51")); + // total desconta os órfãos já identificados, em vez de anunciar 200. + expect(result.total).toBe(150); + expect(result.totalPages).toBe(3); + expect(result.hasNext).toBe(true); + }); + + it("remove do índice global os IDs sem documento (auto-reparo)", async () => { + const ids = makeIds(100); + const orphans = ["id-1", "id-2", "id-3"]; + const liveIds = new Set(ids.filter((id) => !orphans.includes(id))); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation(hydrateFrom(liveIds)); + + const result = await service.execute({ query: { page: "1", limit: "50" } }); + + expect(mocks.cacheRemoveJobIndexIds).toHaveBeenCalledWith(orphans); + expect(result.jobs).toHaveLength(50); + expect(result.total).toBe(97); + }); + + it("não falha a requisição quando o auto-reparo do índice falha", async () => { + const ids = makeIds(60); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation( + hydrateFrom(new Set(ids.slice(1))), + ); + mocks.cacheRemoveJobIndexIds.mockRejectedValue(new Error("valkey down")); + + const result = await service.execute({ query: { page: "1", limit: "50" } }); + + expect(result.jobs).toHaveLength(50); + expect(mocks.logWarn).toHaveBeenCalled(); + }); + + it("retorna página vazia e coerente quando todos os IDs do índice estão órfãos", async () => { + const ids = makeIds(200); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation( + hydrateFrom(new Set()), + ); + + const result = await service.execute({ query: { page: "1", limit: "50" } }); + + expect(result.jobs).toEqual([]); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(0); + expect(result.hasNext).toBe(false); + }); + + it("limita o esforço de hidratação por requisição (não varre o índice inteiro)", async () => { + const ids = makeIds(100_000); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation( + hydrateFrom(new Set()), + ); + + await service.execute({ query: { page: "1", limit: "50" } }); + + expect(mocks.cacheGetJobsByIdsDetailed).toHaveBeenCalledTimes(10); + }); + + it("pagina corretamente a última página parcial", async () => { + const ids = makeIds(120); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation( + hydrateFrom(new Set(ids)), + ); + + const result = await service.execute({ query: { page: "3", limit: "50" } }); + + expect(result.jobs).toHaveLength(20); + expect(result.jobs[0]).toEqual(job("id-101")); + expect(result.page).toBe(3); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(true); + }); + + it("retorna jobs vazio quando a página solicitada está além do índice", async () => { + const ids = makeIds(10); + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation( + hydrateFrom(new Set(ids)), + ); + + const result = await service.execute({ query: { page: "9", limit: "50" } }); + + expect(result.jobs).toEqual([]); + expect(result.total).toBe(10); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(true); + }); + + it("mantém o caminho de filtros estruturados usando os índices do Valkey", async () => { + mocks.cacheSearchJobIds.mockResolvedValue(["id-1", "id-2"]); + mocks.cacheGetJobsByIds.mockResolvedValue([ + { id: "id-1", title: "Dev Node", company: "ACME", location: "Brasil" }, + { id: "id-2", title: "Dev Node", company: "Globo", location: "Brasil" }, + ]); + + const result = await service.execute({ query: { country: "Brasil" } }); + + expect(mocks.cacheAbsoluteSMembers).not.toHaveBeenCalled(); + expect(result.source).toContain("structured_indexes"); + expect(result.jobs).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it("filtra por empresa mesmo sem índice dedicado, mantendo total coerente", async () => { + const ids = ["id-1", "id-2", "id-3"]; + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIds.mockResolvedValue([ + { id: "id-1", title: "Dev Node", company: "ACME Tecnologia" }, + { id: "id-2", title: "Dev Node", company: "Globo" }, + { id: "id-3", title: "Dev Go", company: "acme labs" }, + ]); + + const result = await service.execute({ query: { company: "acme" } }); + + expect(result.jobs).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.totalPages).toBe(1); + expect(result.source).toContain("post_filter"); + }); + + it("prioriza vagas compatíveis com as tecnologias do perfil do candidato", async () => { + profileMocks.technologies = [{ name: "Go", years: 4 }]; + const ids = ["sem-relacao-1", "sem-relacao-2", "compativel-1"]; + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + // Índice invertido do scraper devolve a vaga compatível com o perfil. + mocks.cacheSearchKeywords.mockResolvedValue(["compativel-1"]); + mocks.cacheGetJobsByIdsDetailed.mockImplementation(async (pageIds: string[]) => ({ + jobs: pageIds.map((id) => ({ + id, + title: id === "compativel-1" ? "Desenvolvedor Go" : "Analista Contábil", + company: "ACME", + })), + missingIds: [], + })); + + const result = await service.execute({ + query: { page: "1", limit: "50" }, + userId: "user-1", + }); + + expect(mocks.cacheSearchKeywords).toHaveBeenCalledWith(["Go"]); + expect((result.jobs[0] as { id: string }).id).toBe("compativel-1"); + expect(result.source).toContain("profile_ranked"); + expect(result.total).toBe(3); + }); + + it("mantém a ordem do índice quando o perfil não tem tecnologias", async () => { + const ids = ["id-1", "id-2"]; + mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); + mocks.cacheGetJobsByIdsDetailed.mockImplementation( + hydrateFrom(new Set(ids)), + ); + + const result = await service.execute({ query: {}, userId: "user-1" }); + + expect(mocks.cacheSearchKeywords).not.toHaveBeenCalled(); + expect(result.source).toBe("valkey_global_index"); + expect((result.jobs[0] as { id: string }).id).toBe("id-1"); + }); +}); diff --git a/backend/tests/unit/scripts/seedCatalogJobs.test.ts b/backend/tests/unit/scripts/seedCatalogJobs.test.ts new file mode 100644 index 00000000..c4388a9b --- /dev/null +++ b/backend/tests/unit/scripts/seedCatalogJobs.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { scoreJobWithTechnologies } from "../../../src/modules/jobs/services/jobMatch.service"; +import type { MatchableJob } from "../../../src/modules/jobs/services/jobMatch.service"; +import { + SEED_CATALOG_JOBS, + SEED_DEV_TECHNOLOGIES, + catalogJobDocument, + catalogJobIndexKeys, +} from "../../../src/scripts/seedCatalogJobs"; + +function scoreOf(spec: (typeof SEED_CATALOG_JOBS)[number]) { + return scoreJobWithTechnologies( + catalogJobDocument(spec) as MatchableJob, + SEED_DEV_TECHNOLOGIES, + ); +} + +describe("seedCatalogJobs", () => { + it("gera cada vaga com o match documentado em expectedMatch", () => { + for (const spec of SEED_CATALOG_JOBS) { + expect(scoreOf(spec).matchScore, spec.title).toBe(spec.expectedMatch); + } + }); + + it("mantém o catálogo ordenado do maior para o menor match", () => { + const scores = SEED_CATALOG_JOBS.map((spec) => scoreOf(spec).matchScore ?? 0); + + expect(scores).toEqual([...scores].sort((a, b) => b - a)); + expect(new Set(scores).size).toBe(scores.length); + }); + + it("casa exatamente as tecnologias declaradas no perfil", () => { + const profile = new Set( + SEED_DEV_TECHNOLOGIES.map((technology) => technology.name), + ); + + for (const spec of SEED_CATALOG_JOBS) { + const expected = spec.technologies.filter((technology) => + profile.has(technology), + ); + + expect(scoreOf(spec).matchedTechnologies ?? [], spec.title).toEqual( + expected, + ); + } + }); + + it("indexa cada vaga nas chaves invertidas que o scraper escreveria", () => { + for (const spec of SEED_CATALOG_JOBS) { + const keys = catalogJobIndexKeys(spec); + + for (const technology of spec.technologies) { + const normalized = technology + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + + expect(keys).toContain(`scraper:jobs:technology:${normalized}`); + expect(keys).toContain(`scraper:jobs:keyword:${normalized}`); + } + + expect(keys).toContain("scraper:jobs:country:brasil"); + } + }); + + it("usa IDs e URLs únicos por vaga", () => { + const documents = SEED_CATALOG_JOBS.map(catalogJobDocument); + + expect(new Set(documents.map((job) => job.id)).size).toBe(documents.length); + expect(new Set(documents.map((job) => job.url)).size).toBe(documents.length); + }); +}); diff --git a/docker-compose.migrate.yml b/docker-compose.migrate.yml index ad48a4e3..837e57af 100644 --- a/docker-compose.migrate.yml +++ b/docker-compose.migrate.yml @@ -10,6 +10,7 @@ services: - ./backend/.env environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + VALKEY_URL: redis://valkey:6379/0 command: sh -c "npm run db:migrate && npm run db:seed && npm run security:backfill-user-pii -- --write" depends_on: postgres: diff --git a/frontend/src/domains/jobs/application/useJobsData.ts b/frontend/src/domains/jobs/application/useJobsData.ts index e370fe3c..077ce39e 100644 --- a/frontend/src/domains/jobs/application/useJobsData.ts +++ b/frontend/src/domains/jobs/application/useJobsData.ts @@ -3,7 +3,7 @@ import { runScraperRequest, } from "@/domains/jobs/infrastructure/jobsApi"; import type { Job, JobsResponse } from "@/domains/jobs/domain/job.types"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; export type JobsPaginationMeta = Omit; @@ -17,6 +17,9 @@ const EMPTY_META: JobsPaginationMeta = { }; export function useJobsData(page: number = 1, limit: number = 5) { + // Sequencia as requisicoes: uma resposta antiga que chegue depois de uma + // mais recente nao pode sobrescrever a lista exibida. + const requestIdRef = useRef(0); const [jobs, setJobs] = useState([]); const [meta, setMeta] = useState(EMPTY_META); const [loading, setLoading] = useState(false); @@ -24,13 +27,16 @@ export function useJobsData(page: number = 1, limit: number = 5) { const [error, setError] = useState(""); const loadJobs = useCallback(async () => { + const requestId = ++requestIdRef.current; setLoading(true); setError(""); try { const data = await fetchJobsByAPI(page, limit); + if (requestId !== requestIdRef.current) return; + setJobs(data.jobs ?? []); - + setMeta({ total: data.total ?? 0, hasNext: data.hasNext ?? false, @@ -40,6 +46,8 @@ export function useJobsData(page: number = 1, limit: number = 5) { totalPages: data.totalPages ?? 0, }); } catch (err: unknown) { + if (requestId !== requestIdRef.current) return; + setJobs([]); setMeta(EMPTY_META); setError( @@ -48,7 +56,7 @@ export function useJobsData(page: number = 1, limit: number = 5) { : "Erro inesperado ao carregar vagas.", ); } finally { - setLoading(false); + if (requestId === requestIdRef.current) setLoading(false); } }, [page, limit]); diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index 95c3efe3..5e017fd0 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -33,7 +33,6 @@ import { } from "./utils/locationFilters"; import { getModelFilterFromJobTypes, - modelFilterMatchesJob, modelFilterToApiFilter, } from "./utils/jobModelFilters"; import { parseSearchKeywords } from "./utils/searchKeywords"; @@ -238,18 +237,18 @@ export default function NewDashboardPage() { ); const matchedRecommendedJobs = useMemo( () => + // O backend ja calcula o match com o mesmo perfil; so recalculamos aqui + // quando a API nao devolveu score (usuario sem perfil carregado). recommendedJobs.map((job) => - scoreJobWithTechnologies(job, userProfile.technologyExperiences), + job.matchScore > 0 + ? job + : scoreJobWithTechnologies(job, userProfile.technologyExperiences), ), [recommendedJobs, userProfile.technologyExperiences], ); - const displayedRecommendedJobs = useMemo( - () => - matchedRecommendedJobs.filter((job) => - modelFilterMatchesJob(job, filterType), - ), - [filterType, matchedRecommendedJobs], - ); + // O filtro de modelo e aplicado pelo backend (indice do Valkey + pos-filtro). + // Refiltrar aqui escondia vagas ja filtradas e quebrava a contagem da pagina. + const displayedRecommendedJobs = matchedRecommendedJobs; const showPreferenceNotice = !hasUserChangedJobFilters && preferredModelFilter !== "Todos" && diff --git a/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts b/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts index 50bcfbbf..ac0e7bf9 100644 --- a/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts +++ b/frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { User } from "@/domains/auth/domain/auth.types"; import { isApiError } from "@/shared/lib/apiError"; import type { Job, JobStatus, NewJob } from "../types"; @@ -101,6 +101,9 @@ export function useDashboardJobs( }>({ keywords: [], filters: {} }); const [isLoadingJobs, setIsLoadingJobs] = useState(true); const [isRefreshingJobs, setIsRefreshingJobs] = useState(false); + // Contador compartilhado entre carga inicial e buscas: garante que apenas a + // resposta da requisicao mais recente escreva nos estados de recomendacao. + const recommendationRequestIdRef = useRef(0); const loadJobs = useCallback(async () => { if (!user) { @@ -111,6 +114,7 @@ export function useDashboardJobs( return; } + const requestId = ++recommendationRequestIdRef.current; setIsLoadingJobs(true); const cachedTrackedJobs = readCachedTrackedJobs(user.id); if (cachedTrackedJobs.length > 0) { @@ -129,6 +133,8 @@ export function useDashboardJobs( : Promise.resolve(null), ]); + const isStale = requestId !== recommendationRequestIdRef.current; + const loadedTrackedJobs = savedResult.status === "fulfilled" ? savedResult.value : null; const effectiveTrackedJobs = @@ -143,7 +149,11 @@ export function useDashboardJobs( } } - if (recommendedResult.status === "fulfilled" && recommendedResult.value) { + if ( + !isStale && + recommendedResult.status === "fulfilled" && + recommendedResult.value + ) { const savedLinks = new Set( effectiveTrackedJobs.map((job) => job.jobLink), ); @@ -160,9 +170,9 @@ export function useDashboardJobs( if (recommendedResult.status === "rejected") { errors.push("Não foi possível atualizar as vagas recomendadas."); } - if (errors.length > 0) onError?.(errors.join(" ")); + if (errors.length > 0 && !isStale) onError?.(errors.join(" ")); - setIsLoadingJobs(false); + if (!isStale) setIsLoadingJobs(false); }, [initialRecommendationSearch, onError, user]); useEffect(() => { @@ -187,22 +197,30 @@ export function useDashboardJobs( page = 1, limit = recommendedPagination.limit, ) => { + const requestId = ++recommendationRequestIdRef.current; setIsRefreshingJobs(true); try { setLastRecommendationSearch({ keywords, filters }); const result = await searchDashboardJobs(keywords, filters, page, limit); + // Resposta atrasada de um filtro anterior nao pode sobrescrever a atual. + if (requestId !== recommendationRequestIdRef.current) return; + const savedLinks = new Set(trackedJobs.map((job) => job.jobLink)); setRecommendedJobs( result.jobs.filter((job) => !savedLinks.has(job.jobLink)), ); setRecommendedPagination(result.pagination); } catch (error) { + if (requestId !== recommendationRequestIdRef.current) return; + onError?.( errorMessage(error, "Não foi possível procurar novas vagas."), ); throw error; } finally { - setIsRefreshingJobs(false); + if (requestId === recommendationRequestIdRef.current) { + setIsRefreshingJobs(false); + } } }, [onError, recommendedPagination.limit, trackedJobs], diff --git a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts b/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts index 9eda99af..e68c50ab 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts +++ b/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts @@ -75,14 +75,25 @@ type ApiSavedJob = z.infer; type ApiSavedJobEvent = z.infer; type SearchJobsResponse = z.infer; +/** + * Espelha os query params aceitos por GET /jobs/search + * (backend: src/modules/jobs/parsers/jobSearchQuery.parser.ts). + * Listas sao enviadas separadas por virgula, como o backend espera. + */ export type SearchJobFilters = { level?: string; + seniority?: string; location?: string; continent?: string; country?: string; + state?: string; + city?: string; type?: string; model?: string; contract?: string; + family?: string; + technology?: string; + company?: string; matchSort?: Exclude; }; @@ -260,10 +271,8 @@ export function toRecommendedJob(job: ApiSearchJob, index: number): Job { .join(" "), ), level: inferLevel(title), - matchScore: - typeof job.matchScore === "number" - ? job.matchScore - : stableMatchScore(`${title}:${job.company ?? ""}`), + // Sem perfil o backend nao calcula match; nao inventamos um score aqui. + matchScore: typeof job.matchScore === "number" ? job.matchScore : 0, tags: tags.length > 0 ? tags : ["Geral"], posted: formatPublicationDate(job.postedAt), status: "saved", @@ -322,6 +331,40 @@ function toSearchJobsResult( }; } +const FILTER_PARAM_KEYS = [ + "level", + "seniority", + "location", + "continent", + "country", + "state", + "city", + "type", + "model", + "contract", + "family", + "technology", + "company", + "matchSort", +] as const satisfies ReadonlyArray; + +/** + * Remove filtros vazios/nulos para que "limpar filtros" no front realmente + * deixe de enviar o parametro em vez de mandar string vazia. + */ +export function buildFilterParams( + filters: SearchJobFilters, +): Record { + const params: Record = {}; + + for (const key of FILTER_PARAM_KEYS) { + const value = filters[key]?.trim(); + if (value) params[key] = value; + } + + return params; +} + export async function searchDashboardJobs( keywords: string[] = [], filters: SearchJobFilters = {}, @@ -335,14 +378,7 @@ export async function searchDashboardJobs( ...(normalizedKeywords.length > 0 ? { keywords: normalizedKeywords.join(",") } : {}), - ...(filters.level ? { level: filters.level } : {}), - ...(filters.location ? { location: filters.location } : {}), - ...(filters.continent ? { continent: filters.continent } : {}), - ...(filters.country ? { country: filters.country } : {}), - ...(filters.type ? { type: filters.type } : {}), - ...(filters.model ? { model: filters.model } : {}), - ...(filters.contract ? { contract: filters.contract } : {}), - ...(filters.matchSort ? { matchSort: filters.matchSort } : {}), + ...buildFilterParams(filters), page, limit, },