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}`);