diff --git a/README.md b/README.md index c503a93..fe93e48 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ MIT - [x] PostgreSQL + deduplicación por URL - [x] Frontend con buscador y filtros - [x] Detección de sueldo/rango salarial +- [x] Filtro "solo pegas con sueldo publicado" en el listado (`?sueldo=1`) - [x] **GetOnBoard** — API pública v0, sin auth, filtrada a Chile/Remoto (nodos `getonbrd-*` en `n8n/workflow.json`, validado con `n8n/test-getonbrd.js`) - [x] **WorkingNomads** — API pública `/api/exposed_jobs/`, sin auth, filtrada a LatAm/Chile - [x] Digest de Slack 2x/día (9:00 y 15:00) en vez de notificar en cada corrida — evita saturar el canal diff --git a/web/app/components/PegasFiltros.vue b/web/app/components/PegasFiltros.vue index e5ccbe7..a1a6b6e 100644 --- a/web/app/components/PegasFiltros.vue +++ b/web/app/components/PegasFiltros.vue @@ -1,5 +1,5 @@ @@ -65,6 +68,7 @@ function resetFilters() { ({ describe('parseListJobsQuery', () => { it('usa los valores por defecto sin query params', () => { - expect(parseListJobsQuery({})).toEqual({ q: '', categoria: '', fuente: '', pagina: 1, porPagina: 25 }); + expect(parseListJobsQuery({})).toEqual({ q: '', categoria: '', fuente: '', conSueldo: false, pagina: 1, porPagina: 25 }); }); it('recorta espacios en q, categoria y fuente', () => { @@ -18,6 +18,13 @@ describe('parseListJobsQuery', () => { expect(result).toMatchObject({ q: 'vue', categoria: 'frontend', fuente: 'getonboard' }); }); + it('lee conSueldo tanto de "1" como de "true", y lo deja en false para el resto', () => { + expect(parseListJobsQuery({ conSueldo: '1' }).conSueldo).toBe(true); + expect(parseListJobsQuery({ conSueldo: 'true' }).conSueldo).toBe(true); + expect(parseListJobsQuery({ conSueldo: 'false' }).conSueldo).toBe(false); + expect(parseListJobsQuery({ conSueldo: '0' }).conSueldo).toBe(false); + }); + it('clampea porPagina a 50 como máximo', () => { expect(parseListJobsQuery({ porPagina: '999' }).porPagina).toBe(50); }); @@ -44,7 +51,7 @@ describe('listJobs', () => { queryMock.mockResolvedValueOnce({ rows: [{ count: '2' }] }); queryMock.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); - const result = await listJobs({ q: '', categoria: '', fuente: '', pagina: 1, porPagina: 25 }); + const result = await listJobs({ q: '', categoria: '', fuente: '', conSueldo: false, pagina: 1, porPagina: 25 }); expect(result.total).toBe(2); expect(result.pegas).toHaveLength(2); @@ -58,7 +65,7 @@ describe('listJobs', () => { queryMock.mockResolvedValueOnce({ rows: [{ count: '0' }] }); queryMock.mockResolvedValueOnce({ rows: [] }); - await listJobs({ q: 'react', categoria: 'frontend', fuente: 'getonboard', pagina: 2, porPagina: 10 }); + await listJobs({ q: 'react', categoria: 'frontend', fuente: 'getonboard', conSueldo: false, pagina: 2, porPagina: 10 }); const [countSql, countValues] = queryMock.mock.calls[0]; expect(countSql).toContain('categoria = $1'); @@ -70,11 +77,31 @@ describe('listJobs', () => { expect(listValues).toEqual(['frontend', 'getonboard', '%react%', 10, 10]); }); + it('filtra por sueldo publicado sin agregar parametros a la query', async () => { + queryMock.mockResolvedValueOnce({ rows: [{ count: '0' }] }); + queryMock.mockResolvedValueOnce({ rows: [] }); + + await listJobs({ q: '', categoria: '', fuente: '', conSueldo: true, pagina: 1, porPagina: 25 }); + + const [countSql, countValues] = queryMock.mock.calls[0]; + expect(countSql).toContain("(sueldo IS NOT NULL AND TRIM(sueldo) <> '')"); + expect(countValues).toEqual([]); + }); + + it('no filtra por sueldo cuando conSueldo es false', async () => { + queryMock.mockResolvedValueOnce({ rows: [{ count: '0' }] }); + queryMock.mockResolvedValueOnce({ rows: [] }); + + await listJobs({ q: '', categoria: '', fuente: '', conSueldo: false, pagina: 1, porPagina: 25 }); + + expect(queryMock.mock.calls[0][0]).not.toContain('sueldo'); + }); + it('devuelve total 0 si COUNT no trae filas', async () => { queryMock.mockResolvedValueOnce({ rows: [] }); queryMock.mockResolvedValueOnce({ rows: [] }); - const result = await listJobs({ q: '', categoria: '', fuente: '', pagina: 1, porPagina: 25 }); + const result = await listJobs({ q: '', categoria: '', fuente: '', conSueldo: false, pagina: 1, porPagina: 25 }); expect(result.total).toBe(0); }); diff --git a/web/server/api/pegas/index.get.ts b/web/server/api/pegas/index.get.ts index fac3e75..0a27ce0 100644 --- a/web/server/api/pegas/index.get.ts +++ b/web/server/api/pegas/index.get.ts @@ -7,6 +7,7 @@ export interface ListJobsParams { q: string; categoria: string; fuente: string; + conSueldo: boolean; pagina: number; porPagina: number; } @@ -25,6 +26,12 @@ function toStringParam(value: unknown): string { return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? ''); } +/** Acepta `?sueldo=1` (lo que escribe la URL del listado) y `?sueldo=true` (lo que serializa useFetch). */ +function toBoolParam(value: unknown): boolean { + const raw = toStringParam(value).trim().toLowerCase(); + return raw === '1' || raw === 'true'; +} + function toIntParam(value: unknown, fallback: number): number { const parsed = parseInt(toStringParam(value), 10); return Number.isFinite(parsed) ? parsed : fallback; @@ -43,6 +50,7 @@ export function parseListJobsQuery(raw: Record): ListJobsParams q: toStringParam(raw.q).trim(), categoria: toStringParam(raw.categoria).trim(), fuente: toStringParam(raw.fuente).trim(), + conSueldo: toBoolParam(raw.conSueldo), pagina, porPagina, }; @@ -66,6 +74,15 @@ export async function listJobs(params: ListJobsParams): Promise values.push(params.fuente); filters.push(`fuente = $${values.length}`); } + /** + * Sin parametrizar: no hay entrada de usuario en la condición, solo el + * booleano decide si la condición se agrega o no. El `TRIM(...) <> ''` va + * además del IS NOT NULL porque los parsers de n8n guardan tanto NULL como + * string vacío según la fuente, y un '' no es un sueldo publicado. + */ + if (params.conSueldo) { + filters.push(`(sueldo IS NOT NULL AND TRIM(sueldo) <> '')`); + } if (params.q) { values.push(`%${params.q}%`); filters.push(`(titulo || ' ' || empleador || ' ' || descripcion || ' ' || categoria) ILIKE $${values.length}`);