# Hiring

> Jobs and applications on a pipeline per job, candidates from config.subjects, scorecards per stage, interviews, and hire, reject and withdraw events for the app to act on.

Source: https://bettersupabase.com/docs/blocks/hiring

`hiring` is an applicant tracking system on the [pipelines](/docs/blocks/pipelines) foundation. A job is open or closed, each job has its own pipeline of stages, and an application moves a candidate through it, collecting interviews and scorecards on the way, until it ends hired, rejected or withdrawn. A candidate is any record in [`config.subjects`](/docs/blocks/sql#subject-registry), a [parties](/docs/blocks/parties) person by default, so a candidate who applies twice is one record with two applications.

Reading needs `hiring.read`, moving applications, scheduling and scoring needs `hiring.write`, and jobs need `hiring.manage`. Admins get every permission and members none: give recruiters a role with `hiring.read` and `hiring.write`. An interviewer on an interview reads it and scores the application without those permissions.

```bash
better-supabase sql add hiring
```

The module needs `tenant`, `access`, `subjects` and `pipelines`. Installing it binds the `application` type in the pipelines module with one pipeline per job (`scope: "job_id"`):

| Category    | Outcome | Default stages                       |
| ----------- | ------- | ------------------------------------ |
| `active`    | open    | Applied, Screening, Interview, Offer |
| `hired`     | success | Hired                                |
| `rejected`  | failure | Rejected                             |
| `withdrawn` | failure | Withdrawn                            |

Each job's first application seeds its pipeline; an admin adds or renames stages per job with the pipelines client. The category sets the application's `state`.

```ts
import { createHiring } from "better-supabase/blocks/hiring";

const hiring = createHiring({ transport });
const job = await hiring
  .saveJob(organizationId, {
    title: "Backend engineer",
    location: "Utrecht",
    status: "open",
  })
  .orThrow();
const application = await hiring
  .apply(job.id, { id: personId }, { source: "referral" })
  .orThrow();
await hiring
  .scheduleInterview(application.id, {
    startsAt,
    endsAt,
    interviewerIds: [engineerId],
    kind: "technical",
  })
  .orThrow();
```

## Applications [#applications]

`apply(jobId, candidate, { source, ownerId })` adds a candidate to an open job (`HIRING_JOB_NOT_OPEN`); applying again returns the same application. The candidate must be in the organization and readable by the caller (`HIRING_CANDIDATE_FORBIDDEN`). Move it between stages with a stage update or [rank](/docs/blocks/rank) on a board, as any pipeline record.

`hire(id)`, `reject(id, reason)` and `withdraw(id)` move the application to its job pipeline's first stage of that category. Hiring stamps `hiredAt` and records `application.hired` with the candidate: call [`employees.fromApplication`](/docs/blocks/employees#from-a-hired-application) from that event, which makes the employee once and links it with a `created_from` [relation](/docs/blocks/relations), or create your own from an `after_application_hire` hook in the same transaction.

## Interviews and scorecards [#interviews-and-scorecards]

`scheduleInterview` records an interview with its interviewers, who can then read it. `score(id, { rating, recommendation, notes })` saves the caller's scorecard at the application's current stage: a rating from 1 to 4 and a recommendation from `strong_no` to `strong_yes`. Scoring again at the same stage replaces it. `detail(id)` returns the application with its scorecards, its interviews and the average rating.

## Events [#events]

| Event                                                                                       | Data                                                                                                             |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `job.opened`, `job.closed`                                                                  | `organizationId`, `jobId`, `title`                                                                               |
| `application.created`, `application.hired`, `application.rejected`, `application.retracted` | `organizationId`, `applicationId`, `subjectType`, `subjectId`, `jobId`, `candidateType`, `candidateId`, `reason` |
| `scorecard.submitted`, `interview.scheduled`                                                | the same, with `reviewerId` and `recommendation`, or `interviewId` and `startsAt`                                |

## Wire it to ReUI [#wire-it-to-reui]

The ReUI Recruiting Pipeline board reads its columns from the job's pipeline board and moves cards with `rank.move("application", id, { group: stageId })`. Show a card's match score from `detail(id).averageRating`, and its interview date from `interviews`.

## Errors [#errors]

| Hint                           | Cause                                                     |
| ------------------------------ | --------------------------------------------------------- |
| `HIRING_FORBIDDEN`             | the caller lacks `hiring.manage`                          |
| `HIRING_JOB_INVALID`           | a job needs a title and a status of draft, open or closed |
| `HIRING_JOB_NOT_FOUND`         | no such job the caller can change                         |
| `HIRING_JOB_NOT_OPEN`          | the job isn't open                                        |
| `HIRING_CANDIDATE_FORBIDDEN`   | the candidate is unknown or unreadable                    |
| `HIRING_APPLICATION_NOT_FOUND` | no application the caller can change or review            |
| `HIRING_OUTCOME_INVALID`       | close as hired, rejected or withdrawn                     |
| `HIRING_STAGE_MISSING`         | the job's pipeline has no stage of that category          |
| `HIRING_SCORECARD_INVALID`     | a rating from 1 to 4 and a recommendation                 |
| `HIRING_INTERVIEW_INVALID`     | an interview starts before it ends                        |