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