# Jobs & applications

URL: https://developers.uidu.org/docs/domains/jobs

> List job postings, render a careers page, and let candidates apply — from the apply flow to the private applications board.

* **Use it for:** [Public website](https://developers.uidu.org/docs/paths/public-website.md) · [Server integration](https://developers.uidu.org/docs/paths/server-integration.md)
* **Reads:** public token — works in the browser; Bearer for `listApplications`
* **Writes:** Bearer (`apiKey`) — server-side only
* **CLI:** `uidu jobs`, `uidu applications`
* **Auth:** what each token can do and where it may run — [Authentication](https://developers.uidu.org/docs/foundations/authentication.md)

A **job** is a [call](https://developers.uidu.org/docs/domains/calls.md) whose `kind` is `job`; an **application** is a
candidate's answer to it: a contact plus their form response, linked to the job. Together they
cover a careers site: a listing, a detail page with salary and location, an apply form, and the
board of applications received.

## Mental model

```
Call (kind: 'job')          ← listJobs / getJob — a filtered view over calls
  ├─ salaryMin / salaryMax, quantity, expiresAt, primaryAddress
  ├─ form                   ← the application form (questions)
  └─→ Application           ← createApplication / listApplications
        ├─ contact          the candidate
        ├─ formResponse     their answers
        ├─ status           pipeline stage on the board
        └─ aasmState        'draft' → 'completed' (completeApplication) → 'accepted' | 'declined'
```

* **Jobs are a derived view.** There is no `jobs` field in the GraphQL schema: `listJobs` and
  `getJob` call the [calls](https://developers.uidu.org/docs/domains/calls.md) queries and keep only `kind: 'job'`. `Job` is
  the `Call` type, so everything on the Calls page — summary vs full fields, the inlined
  `form`, salaries in whole units — holds here.
* **`listJobs` filters after fetching.** `first` is a page of **calls**; the `kind` filter runs
  on what came back. `listJobs(client, { first: 20 })` can return fewer than 20 jobs when the
  workspace also has other calls.
* **`getJob` returns `null` for a call of another kind**, not only for an unknown id, so a
  careers route can't be used to show a grant call.
* **Applications are first-class**: their own GraphQL type and operations, unlike jobs.
* **A new application is a draft.** `createApplication` leaves it in `draft`, and the call's
  board lists only completed ones: until `completeApplication` runs, nobody sees the candidate.
* **Auth.** Reading jobs works with a public token. Applying is a write: `createApplication`
  and `completeApplication` run in a server action with the `apiKey` client, never from the
  browser. `listApplications` needs the Bearer too: applications hold private candidate data.
  See [Authentication](https://developers.uidu.org/docs/foundations/authentication.md).

## Use cases

The examples import `uidu` from `lib/uidu.ts`, a client built with `publicToken` and `apiKey`
and imported only from server code, as in [Authentication](https://developers.uidu.org/docs/foundations/authentication.md#bearer--the-workspace).

### Careers page

Goal: list the open positions with their salary range and city.

```tsx
// app/careers/page.tsx
import { listJobs } from '@uidu/client';
import { formatSalaryRange } from '@uidu/react';
import { uidu } from '@/lib/uidu';

export default async function CareersPage() {
  // A page of *calls*, filtered to jobs afterwards: ask for more than you show.
  const jobs = await listJobs(uidu, { first: 100 });

  return (
    <ul>
      {jobs.map((job) => {
        const salary = formatSalaryRange(job, {
          currency: 'EUR',
          locale: 'it-IT',
        });
        return (
          <li key={job.id}>
            <a href={`/careers/${encodeURIComponent(job.id)}`}>{job.name}</a>
            {job.primaryAddress?.city && ` · ${job.primaryAddress.city}`}
            {salary && ` · ${salary}`}
          </li>
        );
      })}
    </ul>
  );
}
```

`formatSalaryRange(job, { currency?, locale? })` from `@uidu/react` is a pure function, safe in
Server and Client Components. Defaults: `currency: 'EUR'` (ISO 4217), `locale: 'en-US'`
(BCP 47). It never shows decimals, and returns `string | null`:

```ts
formatSalaryRange({ salaryMin: 30000, salaryMax: 40000 }); // '€30,000–€40,000'
formatSalaryRange({ salaryMin: 30000, salaryMax: 30000 }); // '€30,000'
formatSalaryRange({ salaryMin: 30000, salaryMax: null }); // 'From €30,000'
formatSalaryRange({ salaryMin: null, salaryMax: 40000 }); // 'Up to €40,000'
formatSalaryRange({ salaryMin: null, salaryMax: null }); // null — hide the field
```

The call's own currency isn't in the SDK's selection, so pass `currency` when it isn't euros.

### Job page with an apply form

Goal: one job's page, and an apply form that records an application and submits it, so it
reaches the board.

```tsx
// app/careers/[id]/page.tsx
import { getJob, createApplication, completeApplication } from '@uidu/client';
import {
  DynamicForm,
  RichText,
  formatSalaryRange,
  type DynamicFormValues,
} from '@uidu/react';
import { notFound } from 'next/navigation';
import { uidu } from '@/lib/uidu';

export default async function JobPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const job = await getJob(uidu, { id: decodeURIComponent(id) });
  if (!job) notFound(); // unknown id, or a call that isn't a job

  const jobId = job.id;
  const formId = job.form?.id;

  async function apply(values: DynamicFormValues) {
    'use server';
    const created = await createApplication(uidu, {
      input: {
        attributes: {
          applicableId: jobId,
          contactAttributes: values.contact && {
            email: values.contact.email,
            contactableAttributes: {
              kind: 'person',
              firstName: values.contact.firstName,
              lastName: values.contact.lastName || undefined,
            },
          },
          formResponseAttributes: formId
            ? {
                formId,
                fieldValuesAttributes: values.fieldValues.map((fv) => ({
                  fieldId: fv.fieldId,
                  content: { value: fv.value }, // always wrapped
                })),
              }
            : undefined,
        },
      },
    });
    if (created?.errors?.length)
      return { ok: false as const, errors: created.errors };

    const applicationId = created?.application?.id;
    if (!applicationId)
      return {
        ok: false as const,
        errors: [{ name: 'No application created' }],
      };

    // Without this the application stays a draft and never reaches the board.
    const completed = await completeApplication(uidu, applicationId);
    if (completed?.errors?.length)
      return { ok: false as const, errors: completed.errors };

    return { ok: true as const, meta: { applicationId } };
  }

  const salary = formatSalaryRange(job, { currency: 'EUR', locale: 'it-IT' });

  return (
    <article>
      <h1>{job.name}</h1>
      {salary && <p>{salary}</p>}
      <p>{job.primaryAddress?.formattedAddress}</p>
      {job.body && <RichText doc={job.body} />}
      {job.form && (
        <DynamicForm form={job.form} action={apply} submitLabel="Apply" />
      )}
    </article>
  );
}
```

* **`createApplication`** links the candidate and their answers to the job (`applicableId`) in
  one mutation. The candidate is `contactAttributes` (a new or matched contact) or `contactId`
  (an existing one); the answers are `formResponseAttributes`. Its payload is
  `{ errors, application }`: `application` has `id`, `aasmState`, `summary`, `completedAt`,
  `createdAt`, `contact { id, humanName, email }`, `applicable { id, name, kind }` and
  `formResponse { id }`. It doesn't throw on validation errors: check `errors`.
* **`completeApplication(client, id)`** fires the `complete!` event: it stamps `completedAt`,
  marks the form response completed and notifies whoever follows the call, the same call uidu's
  own public site makes after its last step. It's a no-op on an application already completed.
  Its payload's `application` has `id`, `aasmState`, `completedAt` and `formResponse { id }`.
* **Multi-step flows** use `updateApplication(client, { input: { id, attributes, event? } })`
  to save answers as the candidate goes, then complete. `attributes` is required (pass `{}` when
  you only fire an event), and `event` sits on `input`, not inside `attributes`;
  `completeApplication` is `updateApplication` with `event: 'complete!'` and `attributes: {}`.
* **Attachments** (CV, portfolio) go through the [file upload flow](https://developers.uidu.org/docs/domains/forms.md#file-uploads).
* A transport failure throws a `UiduError` instead — see [Errors](https://developers.uidu.org/docs/foundations/errors.md).

### Applications board

Goal: an internal page listing the applications received, with the candidate, the job and the
pipeline stage.

```tsx
// app/admin/applications/page.tsx — server only, needs the apiKey client
import { listApplications } from '@uidu/client';
import { uidu } from '@/lib/uidu';

export default async function ApplicationsPage() {
  const applications = await listApplications(uidu, { first: 50 });

  return (
    <table>
      <tbody>
        {applications.map((a) => (
          <tr key={a.id}>
            <td>
              {a.contact?.humanName} · {a.contact?.email}
            </td>
            <td>{a.applicable?.__typename === 'Call' && a.applicable.name}</td>
            <td>{a.status?.name ?? a.aasmState}</td>
            <td>
              {a.completedAt &&
                new Date(a.completedAt as string).toLocaleDateString()}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
```

* `listApplications` covers the whole workspace, every job. Filter on `applicable.id` for one
  job's board.
* `aasmState` is the lifecycle (`draft`, `completed`, `accepted`, `declined`); `status`
  (`id`, `name`, `color`) is the stage on the board.
* With a public-token client the list comes back empty rather than failing — check that the
  client has `apiKey`. To read every application, pass `ListApplicationsDocument` to `paginate`
  ([Pagination & field values](https://developers.uidu.org/docs/foundations/pagination-and-field-values.md)).

## API at a glance

Every `@uidu/client` function in **Jobs & applications**, generated from the source — the [full reference](https://developers.uidu.org/docs/reference/client.md#jobs) has the rest of the SDK.

| Function                                                                                                  | Kind  | Auth         | CLI                   | Description                                                                                                                                                                                                               |
| --------------------------------------------------------------------------------------------------------- | ----- | ------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`completeApplication`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/applications.ts) | write | Bearer       | —                     | Submit a draft application, the last step of applying.                                                                                                                                                                    |
| [`createApplication`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/applications.ts)   | write | Bearer       | `applications create` | Submit an application to a job. Pass `attributes.applicableId` (the job's id) plus `contactAttributes` and/or `formResponseAttributes`. Returns the mutation payload as-is (including any `errors`) rather than throwing. |
| [`getJob`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/jobs.ts)                      | read  | public token | `jobs get`            | Fetch a single job posting; returns `null` if the call is not a job.                                                                                                                                                      |
| [`listApplications`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/applications.ts)    | read  | Bearer       | `applications list`   | List applications across the workspace (requires a Bearer token).                                                                                                                                                         |
| [`listJobs`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/jobs.ts)                    | read  | public token | `jobs list`           | List job postings (calls of kind `job`).                                                                                                                                                                                  |
| [`updateApplication`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/applications.ts)   | write | Bearer       | —                     | Update an application, optionally firing a state event (`event: 'complete!'`). Returns the mutation payload as-is (including any `errors`) rather than throwing.                                                          |

**Types:** `Application`, `Job`

**CLI:**

| Entity         | list | get | create | update | delete |
| -------------- | :--: | :-: | :----: | :----: | :----: |
| `jobs`         |   ✓  |  ✓  |    —   |    —   |    —   |
| `applications` |   ✓  |  —  |    ✓   |    —   |    —   |
