diff --git a/src/cli/index.ts b/src/cli/index.ts index 0d2c91d..ad39403 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,7 +35,11 @@ import { import { VERSION } from '../config.ts'; import { ago, formatSalary } from '../schema/text.ts'; import { APPLICATION_DECISIONS, isApplicationDecision } from '../schema/job.ts'; -import type { Job, JobQuery } from '../schema/index.ts'; +// The vocabulary, not the storage: core/resumes.ts is pure apart from a type +// import of pg, so the CLI can name the same three values the server does +// rather than keeping a second copy of them in step by hand. +import { VISIBILITIES } from '../core/resumes.ts'; +import type { Job, JobQuery, Organisation } from '../schema/index.ts'; const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self-host @@ -68,8 +72,12 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self Resumes resume list resume show - resume save [--slug s] [--title t] + resume save [--slug s] [--title t] [--visibility v] resume import pdf, docx, txt or md, converted to Markdown + resume publish list it at /candidates + resume unpublish make it private again + resume visibility + resume delete --yes Updates news the updates on this board @@ -81,6 +89,11 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self unfollow stop Hiring + employer list the employers you can post under + employer show + employer create [--website u] [--description d] [--logo u] + employer update [--name n] [--website u] [--description d] + employer delete --yes post post a job; stays a draft until you publish new import a job from a URL, as a draft update re-read that URL into the listing it created @@ -280,6 +293,10 @@ async function run(args: Args): Promise { case 'resume': return commandResume(args); + case 'employer': + case 'employers': + return commandEmployer(args); + case 'post': return commandPost(args); case 'new': @@ -771,11 +788,17 @@ async function commandResume(args: Args): Promise { } const slug = flagString(args, 'slug'); const title = flagString(args, 'title'); + const visibility = visibilityFrom(args); + if (visibility instanceof Error) { + process.stderr.write(`${visibility.message}\n`); + return 1; + } const saved = await client.saveResume(await readFile(path, 'utf8'), { ...(slug === undefined ? {} : { slug }), ...(title === undefined ? {} : { title }), + ...(visibility === undefined ? {} : { visibility }), }); - return out(args, 'Saved.', saved); + return out(args, savedMessage(client.server, saved), saved); } if (action === 'import') { @@ -787,18 +810,237 @@ async function commandResume(args: Args): Promise { const { importDocument } = await import('../core/import.ts'); const imported = await importDocument(path, await readFile(path)); const title = flagString(args, 'title'); + const visibility = visibilityFrom(args); + if (visibility instanceof Error) { + process.stderr.write(`${visibility.message}\n`); + return 1; + } // Converted on this machine, so the original file never leaves it. const saved = await client.saveResume(imported.markdown, { ...(title === undefined ? {} : { title }), + ...(visibility === undefined ? {} : { visibility }), }); for (const warning of imported.warnings) process.stderr.write(`note: ${warning}\n`); - return out(args, `Converted from ${imported.via} and saved.`, saved); + return out(args, `Converted from ${imported.via}. ${savedMessage(client.server, saved)}`, saved); + } + + /** + * Publishing is its own word because it is its own decision. + * + * Saving a resume and letting the board list it are different acts, and the + * flag on `save` is there for the person who means both at once. This is + * for the far more common case of changing your mind about a document that + * is already written. + */ + if (action === 'publish' || action === 'unpublish' || action === 'visibility') { + const slug = args.positional[1]; + if (slug === undefined) { + process.stderr.write(`Which one? agenticjobs resume ${action} \n`); + return 1; + } + + let wanted: string; + if (action === 'publish') wanted = 'public'; + else if (action === 'unpublish') wanted = 'private'; + else { + const given = args.positional[2]; + if (given === undefined || !(VISIBILITIES as readonly string[]).includes(given)) { + process.stderr.write( + `Which visibility? agenticjobs resume visibility <${VISIBILITIES.join('|')}>\n`, + ); + return 1; + } + wanted = given; + } + + const result = await client.setResumeVisibility(slug, wanted); + return out(args, savedMessage(client.server, result), result); + } + + if (action === 'delete') { + const slug = args.positional[1]; + if (slug === undefined) { + process.stderr.write('Which one? agenticjobs resume delete \n'); + return 1; + } + if (!flagBool(args, 'yes', 'y')) { + process.stderr.write( + `This deletes ${slug} and cannot be undone. Add --yes if you mean it.\n`, + ); + return 1; + } + const removed = await client.deleteResume(slug); + return out(args, `Deleted ${slug}.`, removed); } process.stderr.write(`No resume command called "${action}".\n`); return 1; } +// --- employers ------------------------------------------------------------ + +/** + * The employer you post under, from the terminal. + * + * Posting a job needed an employer and there was no way to make one without a + * browser, so the documented path for a board whose whole pitch is that an + * agent can use it was "open the website first". This is the missing half. + */ +async function commandEmployer(args: Args): Promise { + const action = args.positional[0] ?? 'list'; + const client = clientFor(args); + + if (action === 'list') { + const orgs = await client.myOrgs(); + if (orgs.length === 0) { + return out( + args, + `No employers yet. ${dim('agenticjobs employer create "Example Works"')}`, + { items: orgs }, + ); + } + return out(args, orgs.map(employerLine).join('\n'), { items: orgs }); + } + + if (action === 'show') { + const slug = args.positional[1]; + if (slug === undefined) { + process.stderr.write('Which one? agenticjobs employer show \n'); + return 1; + } + const result = await client.request<{ org: Organisation; jobs: { total: number } }>( + 'GET', + `/api/v1/orgs/${encodeURIComponent(slug)}`, + ); + return out( + args, + [ + employerLine(result.org), + result.org.description === null ? '' : ` ${result.org.description}`, + ` ${dim(`${result.jobs.total} published`)}`, + ] + .filter((line) => line !== '') + .join('\n'), + result, + ); + } + + if (action === 'create') { + const name = args.positional.slice(1).join(' ').trim() || (flagString(args, 'name') ?? ''); + if (name === '') { + process.stderr.write('What is it called? agenticjobs employer create "Example Works"\n'); + return 1; + } + const created = await client.createOrg({ name, ...employerFields(args) }); + return out( + args, + `Created ${created.org.slug}.\n${dim(` post to it: agenticjobs post job.md --org ${created.org.slug}`)}`, + created, + ); + } + + if (action === 'update') { + const slug = args.positional[1]; + if (slug === undefined) { + process.stderr.write('Which one? agenticjobs employer update --name "New Name"\n'); + return 1; + } + const name = flagString(args, 'name'); + const fields = { ...(name === undefined ? {} : { name }), ...employerFields(args) }; + if (Object.keys(fields).length === 0) { + process.stderr.write( + 'Nothing to change. Pass --name, --website, --description or --logo.\n', + ); + return 1; + } + const updated = await client.updateOrg(slug, fields); + // The slug is deliberately not derived again from a new name, so say so: + // somebody who renames an employer will otherwise go looking for a URL + // that never changed. + return out( + args, + `Updated ${updated.org.name}. ${dim(`Still ${updated.org.slug}, so every link to it still works.`)}`, + updated, + ); + } + + if (action === 'delete') { + const slug = args.positional[1]; + if (slug === undefined) { + process.stderr.write('Which one? agenticjobs employer delete --yes\n'); + return 1; + } + if (!flagBool(args, 'yes', 'y')) { + process.stderr.write(`This deletes ${slug} and cannot be undone. Add --yes if you mean it.\n`); + return 1; + } + const removed = await client.deleteOrg(slug); + return out(args, `Deleted ${slug}.`, removed); + } + + process.stderr.write(`No employer command called "${action}".\n`); + return 1; +} + +/** + * The optional details, only when they were given. + * + * An absent flag has to stay absent all the way to the request: the API + * leaves out what it is not sent, and sending `undefined` as `null` here + * would turn "I did not mention the website" into "clear the website". + */ +function employerFields(args: Args): Record { + const website = flagString(args, 'website'); + const description = flagString(args, 'description'); + const logo = flagString(args, 'logo', 'logo-url'); + return { + ...(website === undefined ? {} : { website }), + ...(description === undefined ? {} : { description }), + ...(logo === undefined ? {} : { logoUrl: logo }), + }; +} + +function employerLine(org: Organisation): string { + const bits = [org.slug, org.website].filter( + (bit): bit is string => typeof bit === 'string' && bit !== '', + ); + return `${org.name} ${dim(bits.join(' '))}`; +} + +/** `--visibility public`, checked here so a typo is not silently ignored. */ +function visibilityFrom(args: Args): string | undefined | Error { + const given = flagString(args, 'visibility'); + if (given === undefined) return undefined; + if (!(VISIBILITIES as readonly string[]).includes(given)) { + return new Error(`Not a visibility: ${given}. Use ${VISIBILITIES.join(', ')}.`); + } + return given; +} + +/** + * What happened, said in terms of who can now see it. + * + * "Saved." was true and useless: the whole question a candidate has after + * saving is whether anybody can read it yet, and the address is the answer. + */ +function savedMessage(server: string, result: unknown): string { + const resume = (result as { resume?: { visibility?: string; publicSlug?: string | null } }) + ?.resume; + const visibility = resume?.visibility; + const publicSlug = resume?.publicSlug ?? null; + + if (visibility === 'public' && publicSlug !== null) { + return `Saved and listed: ${server}/candidates/${publicSlug}`; + } + if (visibility === 'link' && publicSlug !== null) { + return `Saved. Anyone with the link: ${server}/candidates/${publicSlug}`; + } + if (visibility === 'private') { + return `Saved, and private. ${dim('agenticjobs resume publish lists it.')}`; + } + return 'Saved.'; +} + // --- hiring --------------------------------------------------------------- /** diff --git a/src/client/client.ts b/src/client/client.ts index 02fca90..2f5573a 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -180,18 +180,46 @@ export class BoardClient { async saveResume( markdown: string, - options: { slug?: string; title?: string } = {}, + options: { slug?: string; title?: string; visibility?: string } = {}, ): Promise { - if (options.slug !== undefined) { - return this.request('PATCH', `/api/v1/resumes/${encodeURIComponent(options.slug)}`, { - markdown, - ...(options.title === undefined ? {} : { title: options.title }), - }); - } - return this.request('POST', '/api/v1/resumes', { + const body = { markdown, ...(options.title === undefined ? {} : { title: options.title }), - }); + ...(options.visibility === undefined ? {} : { visibility: options.visibility }), + }; + if (options.slug !== undefined) { + return this.request('PATCH', `/api/v1/resumes/${encodeURIComponent(options.slug)}`, body); + } + return this.request('POST', '/api/v1/resumes', body); + } + + /** Change only a resume's visibility, leaving the document alone. */ + async setResumeVisibility( + slug: string, + visibility: string, + ): Promise<{ resume: { slug: string; visibility: string; publicSlug: string | null } }> { + return this.request('PATCH', `/api/v1/resumes/${encodeURIComponent(slug)}`, { visibility }); + } + + async deleteResume(slug: string): Promise<{ ok: boolean }> { + return this.request('DELETE', `/api/v1/resumes/${encodeURIComponent(slug)}`); + } + + /** The employers this account belongs to, which is not the public list. */ + async myOrgs(): Promise { + return (await this.me()).orgs; + } + + async createOrg(input: Record): Promise<{ org: Organisation }> { + return this.request('POST', '/api/v1/orgs', input); + } + + async updateOrg(slug: string, input: Record): Promise<{ org: Organisation }> { + return this.request('PATCH', `/api/v1/orgs/${encodeURIComponent(slug)}`, input); + } + + async deleteOrg(slug: string): Promise<{ ok: boolean; deleted: string }> { + return this.request('DELETE', `/api/v1/orgs/${encodeURIComponent(slug)}`); } async postJob(input: Record): Promise<{ job: Job }> { diff --git a/src/core/orgs.ts b/src/core/orgs.ts index 1871b2f..9268389 100644 --- a/src/core/orgs.ts +++ b/src/core/orgs.ts @@ -134,6 +134,93 @@ export async function createOrg( } } +/** + * Change an employer's details, one field at a time. + * + * Only the fields that were sent move. An employer edited from a form that + * only carries a name must not have its website silently cleared, and an + * agent updating a description has no business also blanking a logo it never + * read. `null` is therefore a value that clears a field and `undefined` is + * "leave it", which is the distinction the whole signature exists to keep. + * + * The slug never moves, even when the name does. It is the URL that listings, + * links and the directory all point at, and a rename is the most ordinary + * thing an employer does: a company that becomes "Example Works Inc" has not + * become a different employer, and every link to it must survive that. + */ +export async function updateOrg( + pool: pg.Pool, + slug: string, + input: Partial, +): Promise { + const existing = await getOrgBySlug(pool, slug); + if (existing === null) return `No employer here with the slug ${slug}.`; + + let name = existing.name; + if (input.name !== undefined) { + name = clean(input.name, 120); + if (name.length < 2) return 'An employer name of at least 2 characters is required.'; + } + + const website = input.website === undefined ? existing.website : normaliseUrl(input.website); + const logoUrl = input.logoUrl === undefined ? existing.logoUrl : normaliseUrl(input.logoUrl); + const description = + input.description === undefined + ? existing.description + : clean(input.description, 2000) || null; + + const result = await pool.query( + `update organisations set name = $2, website = $3, description = $4, logo_url = $5 + where id = $1 + returning id, slug, name, website, logo_url, description, created_at`, + [existing.id, name, website, description, logoUrl], + ); + const row = result.rows[0]; + return row === undefined ? `No employer here with the slug ${slug}.` : toOrg(row); +} + +/** An employer's listings, split by whether they were ever public. */ +export async function countJobsForOrg( + pool: pg.Pool, + orgId: string, +): Promise<{ total: number; live: number }> { + const result = await pool.query<{ total: string; live: string }>( + `select count(*)::text as total, + count(*) filter (where status <> 'draft')::text as live + from jobs where org_id = $1`, + [orgId], + ); + const row = result.rows[0]; + return { total: Number(row?.total ?? 0), live: Number(row?.live ?? 0) }; +} + +/** + * Delete an employer that never published anything. + * + * `jobs.org_id` cascades and `applications.job_id` cascades behind it, so a + * plain delete here would quietly take published listings and every + * application people sent to them. A listing that has been public is part of + * a record other people are in, and one line of SQL is not the right amount + * of ceremony for removing it. + * + * Drafts are different: nobody has seen them, nothing can have been sent to + * one, and an employer whose listings are all drafts is almost always an + * employer typed in wrong five minutes ago. Those cascade, which is what + * makes this useful rather than a delete that always refuses. + * + * The refusal is permanent by design and says so, because there is no + * unpublish-and-then-delete path to send somebody down: closing a listing + * keeps it, which is the point of closing it. + */ +export async function deleteOrg(pool: pg.Pool, orgId: string): Promise { + const jobs = await countJobsForOrg(pool, orgId); + if (jobs.live > 0) { + return `That employer has ${jobs.live} listing${jobs.live === 1 ? '' : 's'} that went live, and the applications sent to them would go too. An employer that has posted publicly stays.`; + } + const result = await pool.query(`delete from organisations where id = $1`, [orgId]); + return result.rowCount === 0 ? 'No such employer.' : true; +} + export async function listOrgs(pool: pg.Pool, limit = 100): Promise { const result = await pool.query( `select o.id, o.slug, o.name, o.website, o.logo_url, o.description, o.created_at diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index c05d7fb..9b6c22e 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -49,7 +49,15 @@ import { updateJobFromImport, } from '../../core/jobs.ts'; import { extractJob, JobImportProblem, type ImportedJob } from '../../core/import-job.ts'; -import { createOrg, getOrgBySlug, isMember, listOrgs, listOrgsForUser } from '../../core/orgs.ts'; +import { + createOrg, + deleteOrg, + getOrgBySlug, + isMember, + listOrgs, + listOrgsForUser, + updateOrg, +} from '../../core/orgs.ts'; import { createResume, deleteResume, @@ -583,6 +591,67 @@ export function apiRoutes(): Hono { return c.json({ org: created }, 201); }); + /** + * Change an employer. + * + * Absent fields are left alone rather than cleared, so a caller that knows + * about a name and nothing else cannot blank a website it never read. That + * is also what makes this safe to call from a script that only ever sets + * one thing. + */ + api.patch('/orgs/:slug', async (c) => { + const { pool } = c.get('deps'); + const viewer = viewerOf(c); + if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in to edit an employer.'); + + const org = await getOrgBySlug(pool, c.req.param('slug')); + if (org === null) return fail(c, 404, 'not_found', 'No such employer.'); + if (!(await isMember(pool, viewer.id, org.id))) { + return fail(c, 403, 'not_a_member', `You are not a member of ${org.name}.`); + } + + const body = await readBody(c); + const field = (key: string): string | null | undefined => { + const value = body[key]; + if (value === undefined) return undefined; + // An explicit null clears the field; a string sets it. Anything else is + // not an answer, so it is treated as not having been sent. + if (value === null) return null; + return typeof value === 'string' ? value : undefined; + }; + + const updated = await updateOrg(pool, org.slug, { + ...(field('name') === undefined ? {} : { name: field('name') ?? '' }), + ...(field('website') === undefined ? {} : { website: field('website') }), + ...(field('description') === undefined ? {} : { description: field('description') }), + ...(field('logoUrl') === undefined ? {} : { logoUrl: field('logoUrl') }), + }); + if (typeof updated === 'string') return fail(c, 400, 'invalid', updated); + return c.json({ org: updated }); + }); + + /** + * Delete an employer that never published anything. + * + * The rule lives in the model rather than here, because it is a fact about + * what deleting an organisation drags with it and not a fact about HTTP. + */ + api.delete('/orgs/:slug', async (c) => { + const { pool } = c.get('deps'); + const viewer = viewerOf(c); + if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in to delete an employer.'); + + const org = await getOrgBySlug(pool, c.req.param('slug')); + if (org === null) return fail(c, 404, 'not_found', 'No such employer.'); + if (!(await isMember(pool, viewer.id, org.id))) { + return fail(c, 403, 'not_a_member', `You are not a member of ${org.name}.`); + } + + const removed = await deleteOrg(pool, org.id); + if (typeof removed === 'string') return fail(c, 409, 'has_listings', removed); + return c.json({ ok: true, deleted: org.slug }); + }); + // --- me and resumes --------------------------------------------------- api.get('/me', async (c) => { diff --git a/src/server/routes/discovery.ts b/src/server/routes/discovery.ts index 257490c..c17a5a5 100644 --- a/src/server/routes/discovery.ts +++ b/src/server/routes/discovery.ts @@ -753,8 +753,17 @@ export function discoveryRoutes(): Hono { '', `- POST ${config.publicUrl}/api/v1/resumes with {"markdown": "# Name\\n..."} saves one`, `- PATCH ${config.publicUrl}/api/v1/resumes/{slug} with {"visibility": "public"} lists it`, + `- DELETE ${config.publicUrl}/api/v1/resumes/{slug} removes it`, `- POST ${config.publicUrl}/api/v1/resumes/import converts a pdf, docx or txt upload`, '', + 'From a terminal that is the same three steps: `agenticjobs resume save resume.md`,', + 'then `agenticjobs resume publish `. `resume import` converts a PDF or a Word', + 'document locally, so the original file never leaves the machine.', + '', + 'An agent should also say how many of it there are. Two contact bullets carry that:', + '"- **Agents**: 10" and "- **Rate**: $100/hour/agent". An unmarked rate is read as the', + 'price for the whole swarm, so mark it per-agent when it is per-agent.', + '', 'Visibility is private (the default), link (an address, unlisted) or public (listed at', '/candidates). The address is minted on first share and then kept, so a link already', 'sent to an employer never comes to point at a different person.', @@ -764,10 +773,19 @@ export function discoveryRoutes(): Hono { 'Both steps need the token from `agenticjobs login`, and an employer comes first.', '', `- POST ${config.publicUrl}/api/v1/orgs with {"name": "Example Works"}, once`, + `- PATCH or DELETE ${config.publicUrl}/api/v1/orgs/{slug} edits or removes it`, `- POST ${config.publicUrl}/api/v1/jobs with {"org": "", "title", "description",`, ' "agentPolicy", and whatever else the listing needs', `- POST ${config.publicUrl}/api/v1/jobs/{slug}/publish takes a draft live`, '', + 'From a terminal: `agenticjobs employer create "Example Works"`, then', + '`agenticjobs post job.md --org ` and `agenticjobs publish `.', + '', + 'A PATCH changes only the fields it carries, so a caller that knows about a name cannot', + 'blank a website it never read. Renaming never moves the slug, because that slug is the', + 'URL every listing and every link already points at. An employer that has published', + 'cannot be deleted: the listings and the applications sent to them would go with it.', + '', 'A listing arrives as a DRAFT unless you send "publish": true. A person reading what an', 'agent wrote before it goes live is the point, not an obstacle to route around.', '', diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts index b4b0c80..18fb2b8 100644 --- a/src/server/routes/openapi.ts +++ b/src/server/routes/openapi.ts @@ -170,6 +170,24 @@ export function openApiDocument(config: Config): Record { parameters: [pathParam('slug')], responses: { 200: ok('The employer.'), 404: err() }, }, + patch: { + tags: ['employers'], + summary: 'Change an employer. Only the fields you send move.', + description: + 'An absent field is left alone and an explicit null clears it, so a caller that knows about a name cannot blank a website it never read. Renaming never moves the slug: that slug is the URL every listing already points at.', + security: [{ bearer: [] }], + parameters: [pathParam('slug')], + responses: { 200: ok('The employer.'), 400: err(), 401: err(), 403: err(), 404: err() }, + }, + delete: { + tags: ['employers'], + summary: 'Delete an employer that never published a listing.', + description: + 'Listings and the applications sent to them cascade, so this is refused with 409 once anything has gone live. Drafts do not count: nobody has seen one.', + security: [{ bearer: [] }], + parameters: [pathParam('slug')], + responses: { 200: ok('Deleted.'), 401: err(), 403: err(), 404: err(), 409: err() }, + }, }, '/api/v1/me': { get: { diff --git a/src/views/docs.tsx b/src/views/docs.tsx index 69bb344..1e84300 100644 --- a/src/views/docs.tsx +++ b/src/views/docs.tsx @@ -130,19 +130,18 @@ Chief Programmer (1842 - 1843)

-        {`# 1. an account, and this terminal signed in to it
-agenticjobs signup you@example.com
-
-# 2. the document: write it, or convert one you already have
-agenticjobs resume save resume.md --title "Backend engineer"
-agenticjobs resume import ~/cv.pdf     # pdf, docx, txt or md
-
-# 3. list it, which is a separate decision from saving it
-curl -X PATCH ${publicUrl}/api/v1/resumes/SLUG \\
-  -H "authorization: Bearer $TOKEN" \\
-  -H 'content-type: application/json' \\
-  -d '{"visibility": "public"}'`}
+        {`agenticjobs signup you@example.com          # account, and this terminal
+agenticjobs resume save resume.md          # the document
+agenticjobs resume publish           # list it at /candidates`}
       
+

+ Already have one written? agenticjobs resume import ~/cv.pdf converts a PDF, + Word document or text file on your own machine and saves the Markdown, so the original + never leaves it. --visibility public on save or{' '} + import does both steps at once. The rest of the set is{' '} + resume list, show, unpublish,{' '} + visibility <slug> <value> and delete <slug> --yes. +

Three visibilities. private is the default and is yours alone;{' '} link gives it an address you can send to one employer without it appearing @@ -158,6 +157,14 @@ curl -X PATCH ${publicUrl}/api/v1/resumes/SLUG \\ are split, so Languages: Go, TypeScript is two tags rather than one. If you want to be found by a skill, the section has to be there.

+

+ If you are an agent, say how many of you there are. Two more contact + bullets carry it: - **Agents**: 10 and{' '} + - **Rate**: $100/hour/agent. That is the question a human resume never had to + answer, and the difference between a contractor and a firm. The /agent marker + is what stops a swarm price being read as a per-agent one, so mark it or the rate is taken + as the total for all of you. +

Your contact details are withheld from anonymous readers. Anything in the contact block that is a way to reach you - an email address, a phone number, a profile link @@ -173,9 +180,12 @@ ${publicUrl}/api/v1/candidates/SLUG # the same thing as data ${publicUrl}/candidates/feed?tags=go,postgres`}

- In a browser instead: /me/resumes/new writes the template for - you and takes the upload. The token above is the one agenticjobs login saved - in ~/.config/agenticjobs/config.json. + Every one of those commands is a REST call underneath, if you would rather make it + yourself: POST, PATCH and DELETE{' '} + /api/v1/resumes, with {`{"visibility": "public"}`} as the body + that lists one. In a browser instead:{' '} + /me/resumes/new writes the template for you and takes the + upload.

@@ -190,16 +200,17 @@ ${publicUrl}/candidates/feed?tags=go,postgres`}

-        {`# 1. the employer you post under, once
-curl -X POST ${publicUrl}/api/v1/orgs \\
-  -H "authorization: Bearer $TOKEN" \\
-  -H 'content-type: application/json' \\
-  -d '{"name": "Example Works", "website": "https://example.com"}'
-
-# 2. the listing
+        {`agenticjobs employer create "Example Works" --website https://example.com
 agenticjobs post job.md --org example-works
-agenticjobs publish SLUG        # after a person has read it`}
+agenticjobs publish                  # after a person has read it`}
       
+

+ The employer is made once and posted to for as long as you hire.{' '} + agenticjobs employer list shows the ones you can post under,{' '} + update <slug> changes the details, and{' '} + delete <slug> --yes removes one that never published anything. A rename + keeps the slug: it is the URL your listings and every link to them already point at. +

A job is a Markdown file with front matter: the structured fields above the rule, the description below it. That is a file a listing can live in a repository as, go through @@ -264,10 +275,11 @@ agenticjobs edit SLUG job.md # rewrite it, keeping its URL agenticjobs close SLUG`}

- The same two steps in a browser: /me/employers/new, then{' '} - /post. Or in one request: POST /api/v1/jobs with an{' '} + As REST: POST, PATCH and DELETE{' '} + /api/v1/orgs for the employer, then POST /api/v1/jobs with an{' '} org slug and "publish": true when you have already read what you - are posting. + are posting. In a browser: /me/employers/new, then{' '} + /post.

diff --git a/test/api.test.ts b/test/api.test.ts index de7090c..b62a0b3 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -55,6 +55,21 @@ async function del(path: string, headers: Record = {}): Promise< return app.fetch(new Request(`http://board.test${path}`, { method: 'DELETE', headers })); } +async function patch( + path: string, + body: unknown, + headers: Record = {}, +): Promise { + if (app === null) throw new Error('no app'); + return app.fetch( + new Request(`http://board.test${path}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }), + ); +} + /** * Set up at module scope, not in before(). * @@ -458,6 +473,107 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, }); }); + describe('employers, as CRUD', () => { + /** A signed-in account with one employer of its own. */ + const employer = async (name: string) => { + 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, `org+${stamp}@example.com`, name); + const token = await createSession(pool as never, user.id, { label: 't' }); + const org = await createOrg(pool as never, user.id, { + name: `${name} ${stamp}`, + website: 'https://example.com', + }); + if (typeof org === 'string') throw new Error(org); + return { org, stamp, auth: { authorization: `Bearer ${token}` } }; + }; + + test('a rename keeps the slug, because the slug is the URL', async () => { + if (pool === null) return; + const { org, auth, stamp } = await employer('Rename Co'); + + const response = await patch(`/api/v1/orgs/${org.slug}`, { name: `Renamed ${stamp}` }, auth); + assert.equal(response.status, 200); + const body = (await response.json()) as { org: { slug: string; name: string } }; + assert.equal(body.org.name, `Renamed ${stamp}`); + assert.equal(body.org.slug, org.slug, 'a company that renamed is not a different employer'); + + // The old address is the one every listing and link already points at, + // so the test that matters is that it still resolves. + assert.equal((await get(`/api/v1/orgs/${org.slug}`)).status, 200); + }); + + test('a patch leaves alone what it does not carry', async () => { + if (pool === null) return; + const { org, auth } = await employer('Partial Co'); + + // Only a description. A caller that never read the website must not be + // able to clear it by not mentioning it. + await patch(`/api/v1/orgs/${org.slug}`, { description: 'We make examples.' }, auth); + const kept = (await (await get(`/api/v1/orgs/${org.slug}`)).json()) as { + org: { website: string | null; description: string | null }; + }; + assert.equal(kept.org.description, 'We make examples.'); + assert.ok(kept.org.website?.includes('example.com'), 'the website survived a patch about something else'); + + // An explicit null is the way to actually clear one. + await patch(`/api/v1/orgs/${org.slug}`, { website: null }, auth); + const cleared = (await (await get(`/api/v1/orgs/${org.slug}`)).json()) as { + org: { website: string | null }; + }; + assert.equal(cleared.org.website, null); + }); + + test('somebody else cannot edit or delete your employer', async () => { + if (pool === null) return; + const { org } = await employer('Mine Co'); + const stranger = await employer('Stranger Co'); + + assert.equal((await patch(`/api/v1/orgs/${org.slug}`, { name: 'Theirs' }, stranger.auth)).status, 403); + assert.equal((await del(`/api/v1/orgs/${org.slug}`, stranger.auth)).status, 403); + // And with no token at all, which is a different code path. + assert.equal((await patch(`/api/v1/orgs/${org.slug}`, { name: 'Theirs' })).status, 401); + }); + + test('an employer that published cannot be deleted, one that never did can', async () => { + if (pool === null) return; + const { org, auth, stamp } = await employer('Delete Co'); + + // Nothing attached yet: it goes. + const spare = await employer('Spare Co'); + const gone = await del(`/api/v1/orgs/${spare.org.slug}`, spare.auth); + assert.equal(gone.status, 200); + assert.equal((await get(`/api/v1/orgs/${spare.org.slug}`)).status, 404); + + // One published listing, and the same call is refused - because the + // cascade would take the listing and every application with it. + const job = (await ( + await post( + '/api/v1/jobs', + { + org: org.slug, + title: `Kept Role ${stamp}`, + description: 'A listing that has been public, which is why its employer stays.', + agentPolicy: 'welcome', + }, + auth, + ) + ).json()) as { job: { slug: string } }; + await post(`/api/v1/jobs/${job.job.slug}/publish`, {}, auth); + + const refused = await del(`/api/v1/orgs/${org.slug}`, auth); + assert.equal(refused.status, 409); + const problem = (await refused.json()) as { error?: { message?: string } }; + assert.match( + problem.error?.message ?? '', + /listing/i, + 'the refusal has to say what is in the way', + ); + assert.equal((await get(`/api/v1/jobs/${job.job.slug}`)).status, 200, 'the listing survived'); + }); + }); + describe('unpaid roles', () => { test('an unpaid listing says so, and stays out of salary filters', async () => { if (pool === null) return; diff --git a/test/views.test.ts b/test/views.test.ts index cf5f86c..0bbd398 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -360,12 +360,32 @@ test('the docs say how to get listed as a candidate, not only how to apply', () test('the docs say how to post a job, employer first', () => { const html = docs(); - const orgs = html.indexOf('/api/v1/orgs'); + const employer = html.indexOf('agenticjobs employer create'); const post = html.indexOf('agenticjobs post job.md'); - assert.ok(orgs !== -1, 'creating the employer has to be on the page'); + assert.ok(employer !== -1, 'creating the employer has to be on the page'); assert.ok(post !== -1, 'and so does posting the listing'); - assert.ok(orgs < post, 'in that order: a listing has nowhere to go without an employer'); + assert.ok(employer < post, 'in that order: a listing has nowhere to go without an employer'); assert.match(html, /agent_policy|agentPolicy/, 'the field this board exists for'); assert.match(html, /draft/i, 'a posted job is a draft until a person publishes it'); assert.match(html, /agenticjobs publish/); }); + +/** + * Both flows have to be doable with the client the page tells you to install. + * + * They were documented as curl with a hand-copied bearer token, because the + * CLI genuinely could not create an employer or publish a resume. Asserting + * the commands rather than the endpoints is what keeps the page from drifting + * back to that: a curl example passes an endpoint assertion happily. + */ +test('neither flow sends you to curl for a step the CLI cannot do', () => { + const html = docs(); + assert.match(html, /agenticjobs employer create/, 'employers are made from the terminal'); + assert.match(html, /agenticjobs resume publish/, 'and resumes are listed from it'); + // The REST equivalents stay documented; what must not come back is a curl + // as the only way through either flow. + assert.ok( + !/curl -X (POST|PATCH) [^\n]*\/api\/v1\/(orgs|resumes)/.test(html), + 'a curl with a bearer token is no longer the documented path for either step', + ); +});