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
11 changes: 10 additions & 1 deletion docs/openjob.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ serves at `GET /api/v1/jobs/{slug}`.
"max": 230000,
"currency": "USD",
"period": "year",
"equity": "0.1% - 0.4%"
"equity": "0.1% - 0.4%",
"unpaid": false
},
"tags": ["infrastructure", "agents"],
"stack": ["typescript", "postgres", "rust"],
Expand Down Expand Up @@ -163,9 +164,17 @@ both - the JSON-LD for search engines, the OpenJob document for everything else.
| `remoteRegions` | `applicantLocationRequirements` |
| `location` | `jobLocation` |
| `salary` | `baseSalary`, plus an annualised `estimatedSalary` |
| `salary.unpaid` | nothing; schema.org has no vocabulary for it, so `baseSalary` is simply absent |
| `expiresAt` | `validThrough` |
| `apply.via === "board"` | `directApply: true` |

`salary.unpaid` is the board's own field, and it exists because a null range and an
unpaid role are different facts. A listing that says nothing about pay was probably
written by somebody who could not be bothered; one that says `unpaid` is an internship
or a volunteer post being honest about itself. Reading the first as the second is how
an honest employer gets treated like a careless one. When it is set the range is null,
so an unpaid listing never matches a salary floor and never rises up a salary sort.

The two fields with no equivalent - `agentPolicy` and the address of the application
schema - travel in `additionalProperty`, which is the vocabulary's own escape hatch
and passes every validator. `directApply` is always `true`, because every listing is
Expand Down
12 changes: 12 additions & 0 deletions migrations/0012_unpaid_roles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- An unpaid role, said out loud.
--
-- A null salary range already meant "the employer did not say", which is a
-- different fact from "there is no pay". Unpaid internships and volunteer
-- work had no way to be posted honestly: leaving the range empty made them
-- look like every listing whose author could not be bothered, and putting 0
-- in the range made them sort and filter as a paid job worth nothing.
--
-- A boolean rather than a zero for that reason. The range stays null when
-- this is set, so every salary filter and the salary sort keep excluding
-- these listings for free, and a reader is told rather than left guessing.
alter table jobs add column if not exists salary_unpaid boolean not null default false;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/agenticjobs",
"version": "0.8.0",
"version": "0.9.0",
"description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.",
"license": "MIT",
"type": "module",
Expand Down
5 changes: 5 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,11 @@ async function commandPost(args: Args): Promise<number> {
if (value !== undefined) input[key] = value;
}

// A flag rather than a value, because "unpaid" is a fact about the role and
// not a number. `salary_unpaid: true` in front matter already arrives on its
// own, camel-cased with every other key.
if (flagBool(args, 'salary-unpaid')) input['salaryUnpaid'] = true;

if (input['org'] === undefined) {
process.stderr.write('Which employer? Pass --org <slug>, or put "org:" in the front matter.\n');
return 1;
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
}

/** Kept in step with package.json by the release script. */
export const VERSION = '0.8.0';
export const VERSION = '0.9.0';
export const SOFTWARE_NAME = 'agenticjobs';

/**
Expand Down
42 changes: 33 additions & 9 deletions src/core/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface JobRow {
salary_currency: string;
salary_period: string;
salary_equity: string | null;
salary_unpaid: boolean | null;
tags: string[];
stack: string[];
requirements: string[];
Expand Down Expand Up @@ -70,7 +71,7 @@ interface JobRow {
const SELECT = `
select j.id, j.slug, j.title, j.description, j.employment_type, j.workplace, j.seniority,
j.location, j.remote_regions, j.salary_min, j.salary_max, j.salary_currency,
j.salary_period, j.salary_equity, j.tags, j.stack, j.requirements, j.responsibilities,
j.salary_period, j.salary_equity, j.salary_unpaid, j.tags, j.stack, j.requirements, j.responsibilities,
j.agent_policy, j.apply_via, j.apply_url, j.apply_email, j.apply_schema,
j.apply_source_url, j.status,
j.published_at, j.expires_at, j.created_at, j.updated_at,
Expand Down Expand Up @@ -126,6 +127,7 @@ export function toJob(row: JobRow): Job {
currency: row.salary_currency,
period: isSalaryPeriod(row.salary_period) ? row.salary_period : 'year',
equity: row.salary_equity,
unpaid: row.salary_unpaid === true,
},
tags: row.tags ?? [],
stack: row.stack ?? [],
Expand Down Expand Up @@ -271,6 +273,7 @@ export interface JobInput {
salaryCurrency: string;
salaryPeriod: string;
salaryEquity: string | null;
salaryUnpaid: boolean;
tags: string[];
stack: string[];
requirements: string[];
Expand Down Expand Up @@ -303,8 +306,12 @@ export function normaliseInput(input: Record<string, unknown>, orgId: string): J
const seniority = isSeniority(input['seniority']) ? input['seniority'] : null;
const agentPolicy = isAgentPolicy(input['agentPolicy']) ? input['agentPolicy'] : 'disclose';

const salaryMin = money(input['salaryMin']);
const salaryMax = money(input['salaryMax']);
// Unpaid wins over any number that came with it. A form can post a stale
// range alongside a ticked box, and "unpaid, $40k - $60k a year" is not a
// listing anybody can act on.
const salaryUnpaid = truthy(input['salaryUnpaid']);
const salaryMin = salaryUnpaid ? null : money(input['salaryMin']);
const salaryMax = salaryUnpaid ? null : money(input['salaryMax']);
if (salaryMin !== null && salaryMax !== null && salaryMax < salaryMin) {
return 'The top of the salary range is below the bottom of it.';
}
Expand All @@ -328,6 +335,7 @@ export function normaliseInput(input: Record<string, unknown>, orgId: string): J
salaryCurrency: (clean(input['salaryCurrency'], 3) || 'USD').toUpperCase(),
salaryPeriod: isSalaryPeriod(input['salaryPeriod']) ? input['salaryPeriod'] : 'year',
salaryEquity: clean(input['salaryEquity'], 60) || null,
salaryUnpaid,
tags: parseList(input['tags'], 12),
stack: parseList(input['stack'], 20),
requirements: lines(input['requirements'], 20),
Expand Down Expand Up @@ -394,6 +402,20 @@ export function normaliseApplySchema(input: unknown): ApplySchema | null {
return out.length === 0 ? null : { fields: out };
}

/**
* A boolean as it arrives from either surface.
*
* An HTML checkbox posts the string "on" and posts nothing at all when it is
* clear, while the API sends a real boolean. Both have to mean the same thing,
* and an absent field has to read as false rather than as "unchanged", or a
* form that clears the box would never clear it.
*/
function truthy(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return false;
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}

function money(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number.parseInt(String(value).replace(/[^0-9]/g, ''), 10);
Expand Down Expand Up @@ -440,10 +462,10 @@ export async function createJob(pool: pg.Pool, input: JobInput): Promise<Job> {
`insert into jobs (
slug, org_id, title, description, employment_type, workplace, seniority, location,
remote_regions, salary_min, salary_max, salary_currency, salary_period, salary_equity,
tags, stack, requirements, responsibilities, agent_policy, apply_via, apply_url,
apply_email, apply_schema, apply_source_url, expires_at, status
salary_unpaid, tags, stack, requirements, responsibilities, agent_policy, apply_via,
apply_url, apply_email, apply_schema, apply_source_url, expires_at, status
) values (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,'draft'
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,'draft'
) returning id`,
[
slug,
Expand All @@ -460,6 +482,7 @@ export async function createJob(pool: pg.Pool, input: JobInput): Promise<Job> {
input.salaryCurrency,
input.salaryPeriod,
input.salaryEquity,
input.salaryUnpaid,
input.tags,
input.stack,
input.requirements,
Expand Down Expand Up @@ -554,9 +577,9 @@ export async function editJob(pool: pg.Pool, id: string, input: JobInput): Promi
set title = $2, description = $3, employment_type = $4, workplace = $5,
seniority = $6, location = $7, remote_regions = $8,
salary_min = $9, salary_max = $10, salary_currency = $11,
salary_period = $12, salary_equity = $13,
tags = $14, stack = $15, requirements = $16, responsibilities = $17,
agent_policy = $18, expires_at = $19, updated_at = now()
salary_period = $12, salary_equity = $13, salary_unpaid = $14,
tags = $15, stack = $16, requirements = $17, responsibilities = $18,
agent_policy = $19, expires_at = $20, updated_at = now()
where id = $1`,
[
id,
Expand All @@ -572,6 +595,7 @@ export async function editJob(pool: pg.Pool, id: string, input: JobInput): Promi
input.salaryCurrency,
input.salaryPeriod,
input.salaryEquity,
input.salaryUnpaid,
input.tags,
input.stack,
input.requirements,
Expand Down
11 changes: 11 additions & 0 deletions src/schema/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ export interface Salary {
currency: string;
period: SalaryPeriod;
equity: string | null;
/**
* The role pays nothing, and the employer is saying so.
*
* Distinct from a null range, which means only that nobody filled the field
* in. An unpaid internship and a listing whose author skipped the pay
* section rendered identically before this, so the honest employer looked
* like the careless one. The range stays null when this is set, which keeps
* unpaid work out of every salary filter and off the top of the salary
* sort without a special case anywhere.
*/
unpaid: boolean;
}

export interface Organisation {
Expand Down
11 changes: 10 additions & 1 deletion src/schema/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,22 @@ const SYMBOLS: Record<string, string> = {
JPY: '\u00A5',
};

/** "$120k - $160k a year", or null when the employer did not say. */
/**
* "$120k - $160k a year", "Unpaid", or null when the employer did not say.
*
* The three are different answers and the reader is owed the difference. A
* null here means the field was left empty; "Unpaid" means somebody ticked a
* box saying the role pays nothing, which is a thing an internship is allowed
* to be as long as it is not hidden.
*/
export function formatSalary(salary: {
min: number | null;
max: number | null;
currency: string;
period: string;
unpaid?: boolean;
}): string | null {
if (salary.unpaid === true) return 'Unpaid';
if (salary.min === null && salary.max === null) return null;
const money = (amount: number): string => {
const symbol = SYMBOLS[salary.currency.toUpperCase()] ?? `${salary.currency.toUpperCase()} `;
Expand Down
14 changes: 14 additions & 0 deletions src/views/post.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ export const PostJobPage: FC<{
<p class="hint" style="margin-top:0">
A listing without a number gets fewer and worse applications. Say the range.
</p>
<p class="hint" style="margin-top:0">
<label>
<input
type="checkbox"
name="salaryUnpaid"
value="on"
checked={values['salaryUnpaid'] === 'on'}
/>{' '}
This role is unpaid
</label>{' '}
An unpaid internship or volunteer post says so here. It reads as Unpaid rather
than as a listing whose author skipped the question, and any range below is
ignored.
</p>
<div class="row">
<input class="input" type="number" name="salaryMin" placeholder="from" style="width:8rem" value={values['salaryMin'] ?? ''} />
<input class="input" type="number" name="salaryMax" placeholder="to" style="width:8rem" value={values['salaryMax'] ?? ''} />
Expand Down
55 changes: 55 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,61 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` },
});
});

describe('unpaid roles', () => {
test('an unpaid listing says so, and stays out of salary filters', async () => {
if (pool === null) return;
const { createSession, ensureUser } = await import('../dist/core/auth.js');
const { createOrg } = await import('../dist/core/orgs.js');
const stamp = `${Date.now()}${Math.random().toString(36).slice(2, 7)}`;
const user = await ensureUser(pool as never, `unpaid+${stamp}@example.com`, 'Unpaid Co');
const token = await createSession(pool as never, user.id, { label: 't' });
const org = await createOrg(pool as never, user.id, { name: `Unpaid ${stamp}` });
if (typeof org === 'string') throw new Error(org);
const auth = { authorization: `Bearer ${token}` };

const created = (await (
await post(
'/api/v1/jobs',
{
org: org.slug,
title: `Research Intern ${stamp}`,
description: 'An unpaid research internship, said out loud rather than left blank.',
employmentType: 'internship',
agentPolicy: 'welcome',
salaryUnpaid: true,
// Sent alongside on purpose: unpaid has to win, or a listing can
// claim both at once.
salaryMin: 40_000,
salaryMax: 60_000,
},
auth,
)
).json()) as { job: { slug: string; salary: Record<string, unknown> } };

assert.equal(created.job.salary['unpaid'], true);
assert.equal(created.job.salary['min'], null, 'the range does not survive the tick');
assert.equal(created.job.salary['max'], null);

await post(`/api/v1/jobs/${created.job.slug}/publish`, {}, auth);

// It round-trips through the read path, not just the write response.
const read = (await (await get(`/api/v1/jobs/${created.job.slug}`)).json()) as {
job: { salary: Record<string, unknown> };
};
assert.equal(read.job.salary['unpaid'], true);

// And somebody filtering for paying work never sees it. This is the
// reason it is a boolean and not a zero in the range.
const filtered = (await (await get('/api/v1/jobs?salaryMin=1&limit=100')).json()) as {
items: { slug: string }[];
};
assert.ok(
!filtered.items.some((item) => item.slug === created.job.slug),
'an unpaid listing must not match a salary floor',
);
});
});

describe('deciding on an application', () => {
/**
* An employer, a published listing, and one application sitting on it.
Expand Down
71 changes: 71 additions & 0 deletions test/jobs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Job input and the way pay is described.
*
* The governing rule under test: "unpaid" and "not stated" are different
* answers, and a reader is owed the difference. Everything here is pure, so
* it runs with no database.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { formatSalary } from '../dist/schema/text.js';
import { normaliseInput } from '../dist/core/jobs.js';

const BASE = {
title: 'Research Intern',
description: 'A real description, long enough to pass the minimum length check.',
employmentType: 'internship',
};

test('an unspecified salary and an unpaid one read differently', () => {
assert.equal(
formatSalary({ min: null, max: null, currency: 'USD', period: 'year' }),
null,
'nobody filled it in, so the page says nothing',
);
assert.equal(
formatSalary({ min: null, max: null, currency: 'USD', period: 'year', unpaid: true }),
'Unpaid',
'the employer said there is no pay, so the page says so',
);
assert.equal(
formatSalary({ min: 120_000, max: 160_000, currency: 'USD', period: 'year', unpaid: false }),
'$120k - $160k a year',
'and a paid role is unchanged',
);
});

test('unpaid beats any range that came with it', () => {
// A form can post a stale range alongside a ticked box. "Unpaid, $40k a
// year" is not a listing anybody can act on.
const input = normaliseInput(
{ ...BASE, salaryUnpaid: 'on', salaryMin: '40000', salaryMax: '60000' },
'org-1',
);
assert.equal(typeof input, 'object', String(input));
assert.equal(input.salaryUnpaid, true);
assert.equal(input.salaryMin, null, 'the range is cleared, not kept alongside');
assert.equal(input.salaryMax, null);
});

test('a checkbox and an API boolean mean the same thing', () => {
// An HTML checkbox posts "on"; the API sends a real boolean. Both have to
// land in the same column.
for (const value of ['on', 'true', '1', 'yes', true]) {
const input = normaliseInput({ ...BASE, salaryUnpaid: value }, 'org-1');
assert.equal(input.salaryUnpaid, true, `${String(value)} should mean unpaid`);
}
});

test('an absent checkbox clears it, rather than leaving it unchanged', () => {
// A cleared checkbox posts nothing at all. Reading that as "unchanged"
// would make an unpaid listing impossible to correct.
const input = normaliseInput({ ...BASE, salaryMin: '1000' }, 'org-1');
assert.equal(input.salaryUnpaid, false);
assert.equal(input.salaryMin, 1000, 'and a paid range still comes through');

for (const value of ['off', 'false', '0', '', undefined]) {
const cleared = normaliseInput({ ...BASE, salaryUnpaid: value }, 'org-1');
assert.equal(cleared.salaryUnpaid, false, `${String(value)} should not mean unpaid`);
}
});
Loading
Loading