From b01da9f6d289f663ee1bc80bd0b38f591a131eb7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 12:02:13 +0000 Subject: [PATCH] 0.9.0: an unpaid role can say so There was no way to post an unpaid internship. Leaving the salary range empty already meant "the employer did not fill this in", so an honest unpaid listing was indistinguishable from a careless one, and putting 0 in the range made it sort and filter as a paid job worth nothing. salary.unpaid is a boolean for exactly that reason. When it is set the range stays null, so every salary filter and the salary sort keep excluding these listings with no special case anywhere, and the page prints "Unpaid" instead of nothing. Unpaid wins over any number that arrives 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. Reachable from every surface that can post: a checkbox on /post, the salaryUnpaid field on the API, --salary-unpaid on the CLI, and salary_unpaid in job file front matter, which already camel-cased its way through. schema.org has no vocabulary for unpaid work, so baseSalary is simply absent, which is what it already did for a null range. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ --- docs/openjob.md | 11 ++++- migrations/0012_unpaid_roles.sql | 12 ++++++ package.json | 2 +- src/cli/index.ts | 5 +++ src/config.ts | 2 +- src/core/jobs.ts | 42 +++++++++++++++---- src/schema/job.ts | 11 +++++ src/schema/text.ts | 11 ++++- src/views/post.tsx | 14 +++++++ test/api.test.ts | 55 +++++++++++++++++++++++++ test/jobs.test.ts | 71 ++++++++++++++++++++++++++++++++ test/views.test.ts | 32 ++++++++++++++ 12 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 migrations/0012_unpaid_roles.sql create mode 100644 test/jobs.test.ts diff --git a/docs/openjob.md b/docs/openjob.md index 9ae0b14..da01753 100644 --- a/docs/openjob.md +++ b/docs/openjob.md @@ -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"], @@ -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 diff --git a/migrations/0012_unpaid_roles.sql b/migrations/0012_unpaid_roles.sql new file mode 100644 index 0000000..f9bb993 --- /dev/null +++ b/migrations/0012_unpaid_roles.sql @@ -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; diff --git a/package.json b/package.json index a31e7fd..90484c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/index.ts b/src/cli/index.ts index 9f5c53b..a6f94e3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -853,6 +853,11 @@ async function commandPost(args: Args): Promise { 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 , or put "org:" in the front matter.\n'); return 1; diff --git a/src/config.ts b/src/config.ts index 28d785b..fff1272 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'; /** diff --git a/src/core/jobs.ts b/src/core/jobs.ts index a415559..f780d35 100644 --- a/src/core/jobs.ts +++ b/src/core/jobs.ts @@ -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[]; @@ -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, @@ -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 ?? [], @@ -271,6 +273,7 @@ export interface JobInput { salaryCurrency: string; salaryPeriod: string; salaryEquity: string | null; + salaryUnpaid: boolean; tags: string[]; stack: string[]; requirements: string[]; @@ -303,8 +306,12 @@ export function normaliseInput(input: Record, 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.'; } @@ -328,6 +335,7 @@ export function normaliseInput(input: Record, 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), @@ -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); @@ -440,10 +462,10 @@ export async function createJob(pool: pg.Pool, input: JobInput): Promise { `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, @@ -460,6 +482,7 @@ export async function createJob(pool: pg.Pool, input: JobInput): Promise { input.salaryCurrency, input.salaryPeriod, input.salaryEquity, + input.salaryUnpaid, input.tags, input.stack, input.requirements, @@ -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, @@ -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, diff --git a/src/schema/job.ts b/src/schema/job.ts index f75cb71..7a05bdf 100644 --- a/src/schema/job.ts +++ b/src/schema/job.ts @@ -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 { diff --git a/src/schema/text.ts b/src/schema/text.ts index e6ee39d..cd9c154 100644 --- a/src/schema/text.ts +++ b/src/schema/text.ts @@ -80,13 +80,22 @@ const SYMBOLS: Record = { 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()} `; diff --git a/src/views/post.tsx b/src/views/post.tsx index a1f5ea0..3ca80c9 100644 --- a/src/views/post.tsx +++ b/src/views/post.tsx @@ -107,6 +107,20 @@ export const PostJobPage: FC<{

A listing without a number gets fewer and worse applications. Say the range.

+

+ {' '} + 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. +

diff --git a/test/api.test.ts b/test/api.test.ts index 1981924..de7090c 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -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 } }; + + 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 }; + }; + 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. diff --git a/test/jobs.test.ts b/test/jobs.test.ts new file mode 100644 index 0000000..b6b2804 --- /dev/null +++ b/test/jobs.test.ts @@ -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`); + } +}); diff --git a/test/views.test.ts b/test/views.test.ts index dcf34c5..e200ab4 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { ResumeEditor } from '../dist/views/me.js'; +import { PostJobPage } from '../dist/views/post.js'; const resume = { id: 'r', @@ -294,3 +295,34 @@ test('an employer can act on an application from the page they read it on', asyn assert.ok(!html.includes('value="reviewing"'), 'the status it is already in is not a button'); assert.ok(!html.includes('value="new"'), 'the candidate-side statuses are never offered'); }); + +test('the post form can say a role is unpaid', () => { + // "No way to post an unpaid internship" was the report: leaving the range + // empty is indistinguishable from not answering, and 0 in the range sorts + // and filters as a paid job worth nothing. + const org = { + id: 'o', + slug: 'acme', + name: 'Acme', + website: null, + logoUrl: null, + description: null, + createdAt: '', + }; + const html = String(PostJobPage({ orgs: [org] })); + assert.match(html, /name="salaryUnpaid"/, 'the control has to exist to be usable'); + assert.match(html, /type="checkbox"/); + // Scoped to this input: the form has other checked controls, so a bare + // search for "checked" passes no matter what this box does. + const box = (source) => /]*name="salaryUnpaid"[^>]*>/.exec(source)?.[0] ?? ''; + assert.ok( + !box(html).includes('checked'), + `off unless the employer says otherwise, got ${box(html)}`, + ); + + const ticked = String(PostJobPage({ orgs: [org], values: { salaryUnpaid: 'on' } })); + assert.ok( + box(ticked).includes('checked'), + `a rejected form comes back with the box still ticked, got ${box(ticked)}`, + ); +});