Add Careerjet-compatible job search params and gap analysis
This commit is contained in:
parent
ff17eb2e4c
commit
b166ff440a
5 changed files with 158 additions and 4 deletions
|
|
@ -60,6 +60,9 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
if search == "" {
|
if search == "" {
|
||||||
search = r.URL.Query().Get("q")
|
search = r.URL.Query().Get("q")
|
||||||
}
|
}
|
||||||
|
if search == "" {
|
||||||
|
search = r.URL.Query().Get("s") // Careerjet-style alias (What)
|
||||||
|
}
|
||||||
|
|
||||||
employmentType := r.URL.Query().Get("employmentType")
|
employmentType := r.URL.Query().Get("employmentType")
|
||||||
if employmentType == "" {
|
if employmentType == "" {
|
||||||
|
|
@ -68,6 +71,9 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
workMode := r.URL.Query().Get("workMode")
|
workMode := r.URL.Query().Get("workMode")
|
||||||
location := r.URL.Query().Get("location")
|
location := r.URL.Query().Get("location")
|
||||||
|
if location == "" {
|
||||||
|
location = r.URL.Query().Get("l") // Careerjet-style alias (Where)
|
||||||
|
}
|
||||||
salaryMinStr := r.URL.Query().Get("salaryMin")
|
salaryMinStr := r.URL.Query().Get("salaryMin")
|
||||||
salaryMaxStr := r.URL.Query().Get("salaryMax")
|
salaryMaxStr := r.URL.Query().Get("salaryMax")
|
||||||
sortBy := r.URL.Query().Get("sortBy")
|
sortBy := r.URL.Query().Get("sortBy")
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,32 @@ func TestGetJobs_Success(t *testing.T) {
|
||||||
assert.Equal(t, 2, response.Pagination.Total)
|
assert.Equal(t, 2, response.Pagination.Total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestGetJobs_ParsesCareerjetAliases(t *testing.T) {
|
||||||
|
mockService := &mockJobService{
|
||||||
|
getJobsFunc: func(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) {
|
||||||
|
if assert.NotNil(t, filter.Search) {
|
||||||
|
assert.Equal(t, "software engineer", *filter.Search)
|
||||||
|
}
|
||||||
|
if assert.NotNil(t, filter.Location) {
|
||||||
|
assert.Equal(t, "Sao Paulo", *filter.Location)
|
||||||
|
}
|
||||||
|
if assert.NotNil(t, filter.LocationSearch) {
|
||||||
|
assert.Equal(t, "Sao Paulo", *filter.LocationSearch)
|
||||||
|
}
|
||||||
|
return []models.JobWithCompany{}, 0, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewJobHandler(mockService)
|
||||||
|
req := httptest.NewRequest("GET", "/jobs?s=software+engineer&l=Sao+Paulo", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.GetJobs(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateJob_Success(t *testing.T) {
|
func TestCreateJob_Success(t *testing.T) {
|
||||||
mockService := &mockJobService{
|
mockService := &mockJobService{
|
||||||
createJobFunc: func(req dto.CreateJobRequest, createdBy string) (*models.Job, error) {
|
createJobFunc: func(req dto.CreateJobRequest, createdBy string) (*models.Job, error) {
|
||||||
|
|
|
||||||
91
docs/CAREERJET_GAP_ANALYSIS.md
Normal file
91
docs/CAREERJET_GAP_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Gap Analysis: GoHorseJobs vs Careerjet (2026)
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Mapear o que já existe no GoHorseJobs e o que ainda falta para alcançar um fluxo equivalente ao Careerjet, com backlog acionável.
|
||||||
|
|
||||||
|
## Escopo analisado
|
||||||
|
- Busca pública de vagas (home + listagem + detalhe)
|
||||||
|
- Filtros e parâmetros de URL
|
||||||
|
- Recursos de candidato (alerta, salvar vaga, currículo)
|
||||||
|
- Recursos de empresa/recrutador
|
||||||
|
|
||||||
|
## Achados principais (resumo executivo)
|
||||||
|
|
||||||
|
### O que já está bom no GoHorseJobs
|
||||||
|
1. **Fluxo base de busca já existe** com listagem, paginação e filtros essenciais (`q`, localização, tipo, modalidade).
|
||||||
|
2. **Detalhe de vaga e candidatura** já cobrem o núcleo do funil candidato.
|
||||||
|
3. **Estrutura multi-perfil** (candidato/recrutador/admin) e módulo de empresas já está presente.
|
||||||
|
|
||||||
|
### Lacunas mais críticas para paridade com Careerjet
|
||||||
|
1. **Compatibilidade de URL estilo Careerjet (`s`/`l`)** não estava completa de ponta a ponta.
|
||||||
|
2. **Filtros “Date posted”, empresa e jornada de trabalho** ainda não estão consolidados no fluxo público.
|
||||||
|
3. **Alertas de vaga por e-mail** ainda não estão evidentes no fluxo público principal.
|
||||||
|
4. **Página de empresas pública com foco em descoberta** precisa de reforço (seguir empresa, vagas da empresa com filtros rápidos).
|
||||||
|
|
||||||
|
## Melhorias implementadas neste ciclo
|
||||||
|
|
||||||
|
### 1) Compatibilidade de URL estilo Careerjet
|
||||||
|
- Home agora envia também os aliases `s` e `l` (além de `q` e `location`) para facilitar compartilhamento e importação de links externos.
|
||||||
|
- Home também envia `workMode` além de `mode` para reduzir inconsistência entre páginas.
|
||||||
|
- Página `/jobs` agora consome `s`/`l`/`mode`/`workMode` além dos parâmetros antigos.
|
||||||
|
|
||||||
|
### 2) Correção de re-fetch para filtros avançados
|
||||||
|
- A listagem de vagas não reexecutava busca ao alterar alguns filtros avançados (salário, moeda, ordenação, suporte a visto).
|
||||||
|
- Ajustado para atualizar resultados ao mudar esses filtros.
|
||||||
|
|
||||||
|
### 3) Backend com alias Careerjet
|
||||||
|
- Endpoint `GET /api/v1/jobs` passou a aceitar `s` como alias de termo de busca e `l` como alias de localização.
|
||||||
|
- Incluído teste automatizado cobrindo esse comportamento.
|
||||||
|
|
||||||
|
## Backlog recomendado (priorizado)
|
||||||
|
|
||||||
|
## P0 (alta prioridade, impacto imediato)
|
||||||
|
1. **Date Posted no backend + frontend**
|
||||||
|
- Backend: aceitar `datePosted` (`24h`, `7d`, `30d`) e filtrar por `created_at`.
|
||||||
|
- Frontend: filtro visível na listagem com UX similar ao Careerjet.
|
||||||
|
2. **Filtro por empresa no público**
|
||||||
|
- Endpoint de jobs com `companyId` já existe; falta UX forte para seleção por empresa.
|
||||||
|
3. **Persistência de buscas recentes**
|
||||||
|
- LocalStorage para anônimos + conta autenticada (sincronização opcional).
|
||||||
|
|
||||||
|
## P1 (médio prazo)
|
||||||
|
4. **Alerta de vagas público**
|
||||||
|
- “Create alert” com confirmação por e-mail e gerenciamento no painel do candidato.
|
||||||
|
5. **Melhorias na página de empresas**
|
||||||
|
- Botão “seguir”, filtro por data e exibição de vagas com metadados completos.
|
||||||
|
6. **Salvar vaga**
|
||||||
|
- Reforçar botão “Salvar” em cards e detalhe para usuários autenticados.
|
||||||
|
|
||||||
|
## P2 (evolução)
|
||||||
|
7. **Navegação Previous/Next no detalhe da vaga**
|
||||||
|
8. **Origem da vaga (source link) quando for agregação externa**
|
||||||
|
9. **Ranking de relevância para ordenação “relevance”**
|
||||||
|
|
||||||
|
## Critérios de aceite sugeridos
|
||||||
|
|
||||||
|
### Busca / URL
|
||||||
|
- Dado um link `/jobs?s=Data+Engineer&l=Recife`, a tela carrega com termo e local preenchidos e lista filtrada.
|
||||||
|
- Dado um link `/jobs?mode=remote`, o filtro de modalidade entra como “remote”.
|
||||||
|
|
||||||
|
### Filtros avançados
|
||||||
|
- Ao alterar ordenação/moeda/faixa salarial, a lista atualiza sem refresh manual.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `GET /api/v1/jobs?s=qa&l=Lisboa` retorna os mesmos resultados que `q` e `location` equivalentes.
|
||||||
|
|
||||||
|
## Plano técnico (sprint recomendado de 2 semanas)
|
||||||
|
|
||||||
|
### Semana 1
|
||||||
|
- Implementar `datePosted` no backend e cobertura de testes.
|
||||||
|
- Expor filtro na UI pública e validar UX.
|
||||||
|
- Persistir “recent searches” localmente.
|
||||||
|
|
||||||
|
### Semana 2
|
||||||
|
- Criar fluxo de alertas (CRUD mínimo + envio inicial).
|
||||||
|
- Melhorar página pública de empresas (seguir + vagas + filtros).
|
||||||
|
- Ajustes de observabilidade (métricas de conversão busca → clique → candidatura).
|
||||||
|
|
||||||
|
## Riscos e dependências
|
||||||
|
- **Dependência de modelagem de dados** para alertas e follows.
|
||||||
|
- **Qualidade dos dados de salário/empresa** impacta filtros e ordenação.
|
||||||
|
- **Internacionalização**: novas labels e mensagens precisam entrar no i18n.
|
||||||
|
|
@ -54,16 +54,29 @@ function JobsContent() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tech = searchParams.get("tech")
|
const tech = searchParams.get("tech")
|
||||||
const q = searchParams.get("q")
|
const q = searchParams.get("q")
|
||||||
|
const s = searchParams.get("s")
|
||||||
const type = searchParams.get("type")
|
const type = searchParams.get("type")
|
||||||
|
const location = searchParams.get("location")
|
||||||
|
const l = searchParams.get("l")
|
||||||
|
const mode = searchParams.get("mode")
|
||||||
|
const workMode = searchParams.get("workMode")
|
||||||
|
|
||||||
if (tech || q) {
|
if (tech || q || s) {
|
||||||
setSearchTerm(tech || q || "")
|
setSearchTerm(tech || q || s || "")
|
||||||
|
setShowFilters(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location || l) {
|
||||||
|
setLocationFilter(location || l || "")
|
||||||
setShowFilters(true)
|
setShowFilters(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "remote") {
|
if (type === "remote") {
|
||||||
setWorkModeFilter("remote")
|
setWorkModeFilter("remote")
|
||||||
setShowFilters(true)
|
setShowFilters(true)
|
||||||
|
} else if (mode || workMode) {
|
||||||
|
setWorkModeFilter(mode || workMode || "all")
|
||||||
|
setShowFilters(true)
|
||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
|
|
@ -121,7 +134,19 @@ function JobsContent() {
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false
|
isMounted = false
|
||||||
}
|
}
|
||||||
}, [currentPage, debouncedSearchTerm, debouncedLocation, typeFilter, workModeFilter])
|
}, [
|
||||||
|
currentPage,
|
||||||
|
debouncedSearchTerm,
|
||||||
|
debouncedLocation,
|
||||||
|
typeFilter,
|
||||||
|
workModeFilter,
|
||||||
|
salaryMin,
|
||||||
|
salaryMax,
|
||||||
|
currencyFilter,
|
||||||
|
visaSupport,
|
||||||
|
sortBy,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const totalPages = Math.ceil(totalJobs / ITEMS_PER_PAGE)
|
const totalPages = Math.ceil(totalJobs / ITEMS_PER_PAGE)
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@ export function HomeSearch() {
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (searchTerm) params.set("q", searchTerm)
|
if (searchTerm) params.set("q", searchTerm)
|
||||||
|
// Compatibilidade estilo Careerjet (What/Where => s/l)
|
||||||
|
if (searchTerm) params.set("s", searchTerm)
|
||||||
if (location) params.set("location", location)
|
if (location) params.set("location", location)
|
||||||
|
if (location) params.set("l", location)
|
||||||
if (type && type !== "all") params.set("type", type)
|
if (type && type !== "all") params.set("type", type)
|
||||||
if (workMode && workMode !== "all") params.set("mode", workMode)
|
if (workMode && workMode !== "all") {
|
||||||
|
params.set("mode", workMode)
|
||||||
|
params.set("workMode", workMode)
|
||||||
|
}
|
||||||
if (salary) params.set("salary", salary)
|
if (salary) params.set("salary", salary)
|
||||||
|
|
||||||
router.push(`/jobs?${params.toString()}`)
|
router.push(`/jobs?${params.toString()}`)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue