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
73 changes: 73 additions & 0 deletions frontend/docs/PAV-182-match-vagas-salvas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# PAV-182 — Viabilidade de recuperar Match das vagas salvas

## Resultado da auditoria

O contrato atual não permite recuperar uma vaga específica do catálogo por
identificador estável. O enriquecimento das vagas salvas não foi implementado.
O Kanban mantém `Não calculado` quando a API não fornece compatibilidade.

## Endpoint e perfil

- O frontend usa `searchDashboardJobs`, em
`src/features/jobs/api/dashboardJobsApi.ts`, para consultar `GET /jobs/search`.
- O endpoint também está disponível sob `/api/v1/jobs/search` conforme a montagem
das rotas em `backend/src/app.ts`.
- O cliente utiliza cookies da sessão (`withCredentials`). O controller envia
`req.session.userId` para `SearchJobsService`, que carrega o perfil atual por
`JobProfileMatchService` e utiliza o cálculo existente de compatibilidade.
- A busca devolve `matchScore`, `matchSource` e `matchedTechnologies` quando
disponíveis. Ausência de score não equivale a um percentual calculado de zero.

## Contrato de consulta

O parser `backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts` aceita:

- `keywords`, `family`, `familyMode`, `technology`, `company`;
- `model`/`type`, `level`, `seniority`, `contract`/`contractType`/`jobTypes`;
- `location`, `continent`, `country`, `state`, `city`;
- `matchSort`/`sort` e paginação `page`, `limit`.

Não há filtro de ID original, ID externo, URL, `jobLink` ou lote de IDs/URLs.
Enviar esses parâmetros não cria uma consulta por identidade: o parser não os
inclui nos filtros. As rotas públicas de catálogo são `/jobs/search` e
`/jobs/filters/options`; não existe rota de detalhe ou lookup do catálogo.
O limite de resultados por página é 100, definido em `backend/src/lib/pagination.ts`.

## Identificadores e correspondência

- A vaga salva possui um UUID de `saved_jobs`, diferente do ID do catálogo.
- Ela preserva `jobLink`, título, empresa, localização, fonte e uma keyword,
mas não uma associação ao ID original do catálogo nem os dados de Match.
- O frontend gera um ID de apresentação `recommended:<link>:<índice>` para a
busca. Esse valor não é um identificador de catálogo aceito pelo endpoint.
- Quando há URL original válida, `jobLink` permite comparar exatamente com
`url` de um resultado recebido. Isso não oferece um mecanismo para localizar
esse resultado sem percorrer a busca, nem garante unicidade no catálogo.
- Se a URL original estiver ausente, o frontend gera um link `canddate.local`
com dados textuais e índice da página; esse fallback não identifica uma vaga
original de forma estável.
- Título igual, empresa parcial ou primeiro resultado textual não comprovam
identidade. Providers diferentes e vagas homônimas não podem ser associados
automaticamente. A busca por empresa usa substring; keywords usam índices
de busca, sem contrato de correspondência exata por URL.

## Decisão de implementação e performance

Nenhuma nova requisição foi adicionada. Não há busca por card, varredura do
catálogo, paginação ilimitada ou cache novo de pontuações. A página atual de
resultados não foi utilizada como mecanismo de recuperação das vagas salvas:
ela não garante disponibilidade após recarregar e pode excluir essas vagas
pelos filtros ativos.

## Dependência para uma solução definitiva

É necessário um contrato autenticado de consulta em lote por identidade estável,
ou enriquecimento de `/saved-jobs` no servidor com associação ao catálogo.
Esse contrato deve resolver correspondências ausentes ou ambíguas explicitamente,
reutilizar o mesmo serviço de Match e o perfil atual, preservar zero real e
retornar score ausente quando não houver cálculo. Deve também definir expiração
e invalidação por mudanças de perfil/catálogo. Não basta persistir um percentual
fixo nem reaproveitar pontuações antigas pelo título.

Essa alteração depende de backend e ficou fora desta etapa. Não há contrato
novo, endpoint proposto implementado ou alteração do cálculo de compatibilidade.
185 changes: 60 additions & 125 deletions frontend/src/app/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
FALLBACK_JOB_FAMILIES,
type JobFamilyOption,
} from "@/features/jobs/api/dashboardJobsApi";
import type { CareerChecklist, SearchPreferences, TechnologyExperience, UserProfile } from "@/features/profile/domain/user";
import type { CareerChecklist, SearchPreferences, UserProfile } from "@/features/profile/domain/user";
import type { Job, JobModelFilter, JobStatus, MatchSort, NewJob } from "@/features/jobs/domain/job";
import {
type ContinentFilter,
Expand All @@ -42,109 +42,6 @@ function getSection(pathname: string) {
return "home";
}

function normalizeMatchText(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}

function matchAliases(technology: string) {
const normalized = normalizeMatchText(technology);
const aliases = new Set([normalized]);

if (normalized.endsWith(" js")) {
aliases.add(normalized.replace(/\s+js$/, "js"));
}
if (normalized.endsWith("js") && normalized.length > 2) {
aliases.add(normalized.replace(/js$/, " js"));
}

return [...aliases].filter(Boolean);
}

function textMatchesAlias(text: string, alias: string) {
if (!alias) return false;
if (alias.includes(" ")) return text.includes(alias);
return ` ${text} `.includes(` ${alias} `);
}

function jobMatchText(job: Job) {
const rawPayload = job.rawPayload ?? {};
const rawValues = Object.values(rawPayload).flatMap((value) =>
Array.isArray(value) ? value : [value],
);

return normalizeMatchText(
[
job.jobTitle,
job.company,
job.location,
job.type,
job.level,
job.tags.join(" "),
...rawValues.map((value) => (typeof value === "string" ? value : "")),
].join(" "),
);
}

function scoreJobWithTechnologies(
job: Job,
technologies: TechnologyExperience[],
): Job {
if (job.rawPayload?.matchSource === "backend_profile") return job;

const normalizedTechnologies = [
...new Set(
technologies
.filter((technology) => technology.name.trim())
.map((technology) => ({
name: technology.name.trim(),
years: Math.max(0, technology.years),
})),
),
];
if (normalizedTechnologies.length === 0) return job;

const text = jobMatchText(job);
const matchedTechnologies = normalizedTechnologies.filter((technology) =>
matchAliases(technology.name).some((alias) =>
textMatchesAlias(text, alias),
),
);

const totalWeight = normalizedTechnologies.reduce(
(total, technology) => total + Math.max(1, technology.years),
0,
);
const matchedWeight = matchedTechnologies.reduce(
(total, technology) => total + Math.max(1, technology.years),
0,
);
const coverage = matchedWeight / totalWeight;
const score =
matchedTechnologies.length === 0
? 45
: Math.min(
99,
55 +
Math.round(coverage * 35) +
Math.min(matchedTechnologies.length * 4, 9),
);

return {
...job,
matchScore: score,
rawPayload: {
...(job.rawPayload ?? {}),
matchedTechnologies: matchedTechnologies.map((item) => item.name),
},
};
}

export default function NewDashboardPage() {
const { user, refreshUser } = useAuth();
const location = useLocation();
Expand All @@ -165,6 +62,20 @@ export default function NewDashboardPage() {
const [matchSort, setMatchSort] = useState<MatchSort>(
() => (searchParams.get("matchSort") as MatchSort) || "default",
);
const [stateFilter, setStateFilter] = useState(
() => searchParams.get("state") || "",
);
const [cityFilter, setCityFilter] = useState(
() => searchParams.get("city") || "",
);
// Captura os filtros da URL uma vez; alterações posteriores usam a busca
// com debounce, sem disparar outra carga inicial sem a ordenação atual.
const [initialRecommendationFilters] = useState(() => ({
state: stateFilter.trim(),
city: cityFilter.trim(),
family: (searchParams.get("family") ?? "").split(",").filter(Boolean).join(","),
matchSort,
}));
const [familyOptions, setFamilyOptions] = useState<JobFamilyOption[]>(
FALLBACK_JOB_FAMILIES,
);
Expand Down Expand Up @@ -228,6 +139,10 @@ export default function NewDashboardPage() {
} else {
nextParams.delete("matchSort");
}
if (stateFilter.trim()) nextParams.set("state", stateFilter.trim());
else nextParams.delete("state");
if (cityFilter.trim()) nextParams.set("city", cityFilter.trim());
else nextParams.delete("city");

if (nextParams.toString() !== searchParams.toString()) {
setSearchParams(nextParams, { replace: true });
Expand All @@ -239,6 +154,8 @@ export default function NewDashboardPage() {
countryFilter,
matchSort,
searchParams,
stateFilter,
cityFilter,
setSearchParams,
]);

Expand Down Expand Up @@ -281,15 +198,28 @@ export default function NewDashboardPage() {
keywords: [],
filters: {
...modelFilterToApiFilter(preferredModelFilter),
...(selectedFamilies.length > 0
...(initialRecommendationFilters.state
? { state: initialRecommendationFilters.state }
: {}),
...(initialRecommendationFilters.city
? { city: initialRecommendationFilters.city }
: {}),
...(initialRecommendationFilters.matchSort !== "default"
? { matchSort: initialRecommendationFilters.matchSort }
: {}),
...(initialRecommendationFilters.family
? {
family: selectedFamilies.join(","),
family: initialRecommendationFilters.family,
familyMode: "primary",
}
: {}),
},
},
[isLoadingUserData, preferredModelFilter, selectedFamilies],
[
isLoadingUserData,
preferredModelFilter,
initialRecommendationFilters,
],
);
const {
trackedJobs,
Expand All @@ -306,24 +236,11 @@ export default function NewDashboardPage() {
initialRecommendationSearch,
});

const matchedTrackedJobs = useMemo(
() =>
trackedJobs.map((job) =>
scoreJobWithTechnologies(job, userProfile.technologyExperiences),
),
[trackedJobs, userProfile.technologyExperiences],
);
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) =>
job.matchScore > 0
? job
: scoreJobWithTechnologies(job, userProfile.technologyExperiences),
),
[recommendedJobs, userProfile.technologyExperiences],
);
// Vagas salvas também usam apenas a compatibilidade recebida da API.
const matchedTrackedJobs = trackedJobs;
// A API ordena globalmente por matchScore. Preservamos a mesma métrica
// recebida, inclusive zero e ausência, sem calcular um score após paginar.
const matchedRecommendedJobs = recommendedJobs;
// 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;
Expand Down Expand Up @@ -407,6 +324,14 @@ export default function NewDashboardPage() {
setHasUserChangedJobFilters(true);
setMatchSort(value);
}, []);
const handleStateFilterChange = useCallback((value: string) => {
setHasUserChangedJobFilters(true);
setStateFilter(value);
}, []);
const handleCityFilterChange = useCallback((value: string) => {
setHasUserChangedJobFilters(true);
setCityFilter(value);
}, []);

const handleSelectedFamiliesChange = useCallback((value: string[]) => {
setHasUserChangedJobFilters(true);
Expand Down Expand Up @@ -497,6 +422,8 @@ export default function NewDashboardPage() {
? { country: countryFilter, location: countryFilter }
: {}),
...(matchSort !== "default" ? { matchSort } : {}),
...(stateFilter.trim() ? { state: stateFilter.trim() } : {}),
...(cityFilter.trim() ? { city: cityFilter.trim() } : {}),
...(selectedFamilies.length > 0
? {
family: selectedFamilies.join(","),
Expand All @@ -523,6 +450,8 @@ export default function NewDashboardPage() {
countryFilter,
continentFilter,
filterLevel,
stateFilter,
cityFilter,
filterType,
matchSort,
selectedFamilies,
Expand Down Expand Up @@ -563,6 +492,8 @@ export default function NewDashboardPage() {
matchSort,
selectedFamilies,
section,
stateFilter,
cityFilter,
]);

const handleRecommendationPageSizeChange = async (limit: number) => {
Expand Down Expand Up @@ -600,6 +531,10 @@ export default function NewDashboardPage() {
setContinentFilter={handleContinentFilterChange}
countryFilter={countryFilter}
setCountryFilter={handleCountryFilterChange}
stateFilter={stateFilter}
setStateFilter={handleStateFilterChange}
cityFilter={cityFilter}
setCityFilter={handleCityFilterChange}
matchSort={matchSort}
setMatchSort={handleMatchSortChange}
selectedFamilies={selectedFamilies}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/dashboard/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function Sidebar() {
className={cn(
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-semibold transition-colors",
active
? "bg-primary text-primary-foreground shadow-sm"
? "bg-[var(--menu-active-background)] text-[color:var(--menu-active-foreground)] shadow-sm ring-1 ring-inset ring-[color:var(--menu-active-border)]"
: "text-slate-500 hover:bg-muted hover:text-foreground dark:text-slate-300",
)}
>
Expand All @@ -62,7 +62,7 @@ export function Sidebar() {
className={cn(
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-semibold transition-colors",
active
? "bg-primary text-primary-foreground shadow-sm"
? "bg-[var(--menu-active-background)] text-[color:var(--menu-active-foreground)] shadow-sm ring-1 ring-inset ring-[color:var(--menu-active-border)]"
: "text-slate-500 hover:bg-muted hover:text-foreground dark:text-slate-300",
)}
>
Expand Down
Loading
Loading