uidudevelopers
Domains

Jobs & applications

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 · Server integration
  • 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

A job is a call 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 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.

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.

Careers page

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

// 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:

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.

// 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.
  • A transport failure throws a UiduError instead — see Errors.

Applications board

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

// 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).

API at a glance

Every @uidu/client function in Jobs & applications, generated from the source — the full reference has the rest of the SDK.

FunctionKindAuthCLIDescription
completeApplicationwriteBearer—Submit a draft application, the last step of applying.
createApplicationwriteBearerapplications createSubmit 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.
getJobreadpublic tokenjobs getFetch a single job posting; returns null if the call is not a job.
listApplicationsreadBearerapplications listList applications across the workspace (requires a Bearer token).
listJobsreadpublic tokenjobs listList job postings (calls of kind job).
updateApplicationwriteBearer—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:

Entitylistgetcreateupdatedelete
jobs✓✓———
applications✓—✓——

On this page