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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ tools/
npm-debug.log
yarn-error.log
yarn-debug.log
PAV-124_REPORT.md

# Test coverage reports
coverage/
Expand Down
42 changes: 41 additions & 1 deletion BACKEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Módulos principais:
- `src/modules/users` — perfis e preferências do usuário (`UsersController`, `UsersService`).
- `src/modules/savedJobs` — CRUD de vagas salvas (`SavedJobsController`, `SavedJobsService`).
- `src/modules/notifications` — notificações do usuário autenticado.
- `src/modules/jobs` — busca, parsing de filtros, fallback pós-filtro e regras de matching/score de vagas.
- `src/modules/jobs` — busca, parsing de filtros, repository com consulta em lotes e regras de matching/score de vagas.
- `src/modules/admin` — usuários admin, permissões, scrapers, auditoria, dashboard e observabilidade.
- `src/modules/email` — envio de e-mails transacionais assíncronos (ver seção [Módulo de E-mail](#módulo-de-e-mail)).

Expand Down Expand Up @@ -526,3 +526,43 @@ Response (200):
---

Os exemplos acima são intencionais e servem como referência rápida para integrar o frontend ou scripts que consomem a API.

## Filtros de famílias profissionais — PAV-124

`GET /api/v1/jobs/search` (também disponível em `/jobs/search`) mantém a autenticação por sessão e o envelope de sucesso com `jobs`, `total`, `page`, `limit`, `totalPages`, `hasNext`, `hasPrev` e `source`.

O parâmetro `family` aceita um ID, IDs separados por vírgula, parâmetros repetidos ou ambos. Exemplos equivalentes:

```text
?family=backend,fullstack
?family=fullstack,backend
?family=backend&family=fullstack
?family=fullstack,backend&family=backend
```

O parser central produz `families: ProfessionalFamily[]` ordenadas lexicograficamente, sem espaços externos, valores vazios ou duplicidades, e `familyMode: "primary" | "any"`. O limite de 13 é aplicado às famílias únicas após normalização. `family=backend,` é válido; `family=` e `family=,,,` retornam 400. IDs são sensíveis à caixa: labels (`Backend`, `Full Stack`, `Dados e IA`), aliases históricos e `other` não são públicos.

As famílias são derivadas de `professionalTaxonomy.ts`, o módulo da PAV-123 já verificado contra `scraper-go/internal/taxonomy/families.json`: backend, frontend, fullstack, mobile, data, devops, platform, qa, security, product, product_design, software e leadership. Nenhuma nova taxonomia foi criada.

- `familyMode=primary`: considera somente `classification.primaryFamily`.
- `familyMode=any` (default): considera principal e relacionadas.
- `familyMode` sem `family` é validado, mas não restringe a busca.
- Repetição de `familyMode`, modo vazio ou diferente dos dois valores retorna 400.
- Famílias usam OR entre si e AND com os outros grupos de filtros.
- `fullstack` não expande para backend/frontend; devops e platform são independentes.

Exemplo: `?family=product,product_design&familyMode=primary&seniority=senior&model=remoto&country=Brasil&contract=clt`. Os parâmetros de modalidade existentes continuam sendo `model`/`type` (`model` prevalece), com os valores atuais `remoto`, `hibrido` e `presencial`. Esta tarefa não acrescenta `modality` ou um filtro `provider`, que não estavam implementados na busca auditada. Keywords, tecnologias, empresa, senioridade, nível, localização, contrato e aliases, paginação e ordenação continuam com seus parsers e predicados existentes.

Erros de validação usam o envelope padrão `{ "code": "INVALID_JOB_FAMILY" | "INVALID_FAMILY_MODE", "message": "..." }`, status 400, antes de consultar vagas ou perfil. Falhas de infraestrutura preservam o envelope legado da busca `{ "message": "...", "error": "..." }`, status 500.

`GET /api/v1/jobs/filters/options` retorna `taxonomyVersion`, as 13 famílias na ordem canônica com IDs/labels e os modos `any` (default) e `primary`. O endpoint mantém a autenticação de `/jobs`, não consulta vagas/contagens/scraper e envia `Cache-Control: public, max-age=3600` e ETag SHA-256 da versão/conteúdo. `If-None-Match` permite 304.

### Consulta, compatibilidade e limites temporários

A busca anterior já incluía principal e relacionadas; o default `any` conserva essa semântica. A validação estrita deixa de aceitar labels e listas explicitamente vazias, conforme o contrato novo. O repository verifica documentos persistidos em lotes de até 200 e aplica todos os filtros antes de contar e selecionar a página. Não há pós-filtro depois da paginação e não são carregados todos os documentos simultaneamente.

Para impedir perdas com índices estruturados incompletos, buscas filtradas usam o índice global de IDs ou a resolução existente de keywords. O campo diagnóstico `source` passa a usar `:verified_batches` nessas buscas. Isso pode recuperar vagas que os índices anteriores omitiam e mudar a ordem incidental do conjunto de candidatos; não existe ordenação cronológica garantida por Sets do Valkey. A ordem das famílias da request não altera o predicado nem o caminho de consulta. Ordenação explícita por match continua global, com desempate pela ordem dos candidatos e apenas IDs/scores retidos; o cálculo de score existente não foi alterado. A busca simples sem filtros/ordenação mantém sua priorização por perfil e hidratação atuais.

Custo temporário: O(N) documentos candidatos por busca filtrada para obter total exato; IDs permanecem em memória. Ordenação por match conserva no máximo `offset + limit` IDs/scores e hidrata novamente a página escolhida. Valkey não oferece snapshot entre essas leituras: expiração/reclassificação concorrente pode mudar documentos durante uma busca. A busca simples legada ainda estima total descontando apenas órfãos observados na hidratação da página; esse comportamento preexistente foi preservado.

Ficam para a próxima PAV: índices separados de famílias principais/relacionadas, reconstrução de índices, cache keys finais, invalidação por reclassificação e evolução de match score. Não houve mudança no Processor Go, autenticação, autorização ou rate limit (a busca não tinha limitador próprio; os limitadores de autenticação permanecem).
2 changes: 2 additions & 0 deletions backend/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ZodError } from "zod";

export type ErrorCode =
| "INVALID_JOB_FAMILY"
| "INVALID_FAMILY_MODE"
| "VALIDATION_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createHash } from "node:crypto";
import type { Request, Response } from "express";
import {
professionalFamilies,
taxonomyVersion,
} from "../types/professionalTaxonomy";

export const jobFilterOptions = {
taxonomyVersion,
families: professionalFamilies,
familyModes: [
{ id: "any", label: "Principal ou relacionada", default: true },
{ id: "primary", label: "Somente família principal", default: false },
],
};
const etag = `"${createHash("sha256").update(JSON.stringify(jobFilterOptions)).digest("hex")}"`;

export function jobFilterOptionsController(_req: Request, res: Response): void {
res.set("Cache-Control", "public, max-age=3600");
res.set("ETag", etag);
// Express handles If-None-Match freshness and 304 on res.json.
res.json(jobFilterOptions);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isAppError } from "../../../lib/errors";
import type { NextFunction, Request, Response } from "express";
import { logWarn } from "../../../logger";
import { jobSearchesTotal } from "../../../metrics/metrics";
Expand All @@ -18,7 +19,7 @@ function hasKeywords(query: Request["query"]): boolean {
export async function searchJobsController(
req: Request,
res: Response,
_next: NextFunction,
next: NextFunction,
): Promise<void> {
jobSearchesTotal.inc({ has_keywords: hasKeywords(req.query) ? "true" : "false" });

Expand All @@ -30,6 +31,10 @@ export async function searchJobsController(

res.json(result);
} catch (error) {
if (isAppError(error)) {
next(error);
return;
}
logWarn("Erro ao buscar vagas no ecossistema Valkey", {
error: (error as Error).message,
});
Expand Down
4 changes: 2 additions & 2 deletions backend/src/modules/jobs/filters/jobSearch.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export function filterJobs(
const city = normalizeComparable(filters.city);
const contract = normalizeComparable(filters.contract);
const types = filters.type.map(normalizeComparable);
const families = filters.family.map(normalizeComparable);
const families = filters.families.map(normalizeComparable);
const technologies = filters.technology.map(normalizeComparable);
const companies = filters.company.map(normalizeComparable);

Expand Down Expand Up @@ -332,7 +332,7 @@ export function filterJobs(
const classification = candidate.classification;
const classifiedFamilies = [
classification?.primaryFamily,
...(classification?.relatedFamilies ?? []),
...(filters.familyMode === "any" ? classification?.relatedFamilies ?? [] : []),
]
.filter(Boolean)
.map((value) => normalizeComparable(String(value)));
Expand Down
50 changes: 50 additions & 0 deletions backend/src/modules/jobs/parsers/familyQuery.parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { AppError } from "../../../lib/errors";
import type { ParsedJobSearchQuery } from "../types/jobSearch.types";
import {
isPublicFamily,
professionalFamilies,
} from "../types/professionalTaxonomy";

export function parseFamilyQuery(
query: Record<string, unknown>,
): Pick<ParsedJobSearchQuery, "families" | "familyMode"> {
const mode = query.familyMode;
if (mode !== undefined && mode !== "any" && mode !== "primary") {
throw new AppError(
"INVALID_FAMILY_MODE",
"familyMode deve ser any ou primary.",
400,
);
}
if (query.family === undefined)
return { families: [], familyMode: mode ?? "any" };

const raw = Array.isArray(query.family) ? query.family : [query.family];
if (raw.some((value) => typeof value !== "string")) {
throw new AppError(
"INVALID_JOB_FAMILY",
"family deve conter IDs canônicos de famílias.",
400,
);
}
const values = [
...new Set(
(raw as string[])
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean),
),
].sort();
if (
values.length === 0 ||
values.length > professionalFamilies.length ||
values.some((value) => !isPublicFamily(value))
) {
throw new AppError(
"INVALID_JOB_FAMILY",
"Informe de 1 a 13 famílias públicas válidas.",
400,
);
}
return { families: values.filter(isPublicFamily), familyMode: mode ?? "any" };
}
5 changes: 3 additions & 2 deletions backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseFamilyQuery } from "./familyQuery.parser";
import type { Request } from "express";
import type { ParsedJobSearchQuery } from "../types/jobSearch.types";

Expand Down Expand Up @@ -27,7 +28,7 @@ export function parseJobSearchQuery(

return {
keywords: queryValues(query.keywords),
family: queryValues(query.family),
...parseFamilyQuery(query),
technology: queryValues(query.technology),
company: queryValues(query.company),
type,
Expand Down Expand Up @@ -61,7 +62,7 @@ export function hasStructuredFilters(filters: ParsedJobSearchQuery): boolean {
filters.continent ||
filters.state ||
filters.city ||
filters.family.length > 0 ||
filters.families.length > 0 ||
filters.technology.length > 0 ||
filters.seniority ||
filters.type.length > 0 ||
Expand Down
73 changes: 73 additions & 0 deletions backend/src/modules/jobs/repositories/jobSearch.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
cacheAbsoluteSMembers,
cacheGetJobsByIds,
cacheSearchKeywords,
} from "../../../lib/cache";
import type { PaginationParams } from "../../../lib/pagination";
import { filterJobs } from "../filters/jobSearch.filter";
import type { ParsedJobSearchQuery } from "../types/jobSearch.types";

const BATCH_SIZE = 200;
type RankedJob = { id: string; rank: number; score: number };

/** Until classification indexes exist, verify persisted documents in bounded batches.
* Keep only the requested page (or its top-ranked prefix), never all job documents.
* Keyword resolution retains the existing union/alias semantics.
*/
export class JobSearchRepository {
async search(
filters: ParsedJobSearchQuery,
pagination: PaginationParams,
enrich?: (jobs: unknown[]) => Promise<unknown[]>,
): Promise<{ jobs: unknown[]; total: number }> {
const ids = [
...new Set(
filters.keywords.length
? await cacheSearchKeywords(filters.keywords)
: await cacheAbsoluteSMembers("scraper:jobs:index"),
),
];
const offset = (pagination.page - 1) * pagination.limit;
const capacity = Math.min(ids.length, offset + pagination.limit);
let total = 0;
const page: unknown[] = [];
let ranked: RankedJob[] = [];
const compare = (a: RankedJob, b: RankedJob) =>
(filters.matchSort === "asc" ? a.score - b.score : b.score - a.score) ||
a.rank - b.rank;

for (let cursor = 0; cursor < ids.length; cursor += BATCH_SIZE) {
const matches = filterJobs(
await cacheGetJobsByIds(ids.slice(cursor, cursor + BATCH_SIZE)),
filters,
);
if (filters.matchSort && enrich) {
const jobs = await enrich(matches);
const candidates = jobs.map((job, index) => ({
id: String((job as { id: string }).id),
rank: total + index,
score: (job as { matchScore?: number }).matchScore ?? 0,
}));
ranked = [...ranked, ...candidates].sort(compare).slice(0, capacity);
} else {
for (const job of matches) {
if (total >= offset && page.length < pagination.limit) page.push(job);
total++;
}
continue;
}
total += matches.length;
}
return {
jobs:
filters.matchSort && enrich
? await cacheGetJobsByIds(
ranked
.slice(offset, offset + pagination.limit)
.map((item) => item.id),
)
: page,
total,
};
}
}
Loading
Loading