# The rota

The rota is a circle's duty roster: **duties** (cleaning the common house, cooking on Thursdays), the **roster** of
people in the draw for each duty, and **shifts** (a duty on certain days) with their **seats**. These endpoints let an
admin's assistant install the rota on a circle and set it up. Read [Start here](start.md) first: its rules apply to
everything on this page. Duties and shifts work like [Events](events.md) and [Posts](posts.md) where it comes to
`external_id` and re-runs, and this page says where they differ.

The rota comes **last** in a move: its roster and seats are real members, so the people must have joined, and been
matched, first (see [Members](members.md)).

## What the API has, and what it does not

- Install the rota on a circle, and ask whether it is there.
- Duties, with their roster (who is in the draw) and their shifts.
- Shifts, with their seats (who is on duty, and who leads).
- **Not in the API:** uninstalling the rota (the admin does that in the app), swap requests between members, and
  checking off a shift's checklist. There is no bulk or repeat endpoint either: **shifts are created one by one**.
- Shifts and seats **in the past are allowed**, and so is any date from 1 January 1990 to 50 years ahead.
- **Author claims do not cover the rota.** A duty or shift has no author, so there is nothing to hand to a member.

Everything sits under `$API`, and a person is always named by their `member_id`: the member's text id from
[Members](members.md), such as `EveABqLyto9L`, never a database number.

## Install it: `POST /circles/:circle_id/rota`

```sh
curl -X POST "$API/circles/412/rota" -H "Authorization: Bearer $KEY"
```

The rota can only be installed by someone who may install an extension on that circle in the app: the circle's owner
or admin, or an owner or admin of the community (also on a sub-circle they are not in). The answers:

| Answer | Meaning |
|---|---|
| `404 not_found` | The circle is not one of this community's. |
| `403 forbidden` | The admin may not install extensions on that circle. |
| `422 rota_not_available` | The rota is not offered to this community (it is not in the community's directory). Nothing was installed, also when an installation exists. Tell the admin. |
| `409 rota_suspended` | The rota is installed but switched off on that circle. Switching it on again is done in the app. |
| `200 OK` | It was already installed and live. Nothing changed. |
| `201 Created` | Installed now. |

Installing tells nobody. Both `200` and `201` answer:

```json
{"circle_id": 412, "created_at": "2026-11-02T09:00:00.000Z", "can_manage": true}
```

`can_manage` says whether the admin may **run** the rota there. Installing and running are not the same right: an
organisation admin who is not in a sub-circle can install the rota on it, but only people who are admins of that
circle in the app can run it (`can_manage` is `false` for the key's admin, and the writes below answer `403`). Ask the
admin to join the sub-circle in the app first.

## Read it: `GET /circles/:circle_id/rota`

```sh
curl "$API/circles/412/rota" -H "Authorization: Bearer $KEY"
```

Answers `200 OK` with the object above, `404 not_found` when the rota is not installed (or not live) on that circle,
and `403 forbidden` when the admin may neither see the rota nor install it.

**Reach.** Reading follows the app: only a member of the circle sees its rota, and the key never widens that. Writing
needs an admin of the circle. Anything outside this community, or on a circle without a live rota, is `404 not_found`;
what the admin may not see or do is `403 forbidden`.

## Duties

```json
{
  "id": 61,
  "external_id": "old-duty-3",
  "circle_id": 412,
  "name": "Cooking on Thursdays",
  "description": "Dinner for about twenty.",
  "reminder_days": 1,
  "checklist": "Check the fridge\nShop on Wednesday",
  "position": 1,
  "created_at": "2026-11-02T09:05:00.000Z"
}
```

| Field | Rules |
|---|---|
| `name` | Required on a create: text of 1 to 60 characters. Never cleared. |
| `description` | Text of at most 500 characters. `null` clears it. |
| `reminder_days` | A whole number of days from 1 to 14: how long before a shift its people are reminded. Default 1. |
| `checklist` | Text of at most 2,000 characters. `null` clears it. |
| `external_id` | Your id for the duty on the old platform. Always send it. |

```sh
curl "$API/rota/duties" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"circle_id": 412, "external_id": "old-duty-3",
       "name": "Cooking on Thursdays",
       "description": "Dinner for about twenty.",
       "reminder_days": 1}'
```

- `POST /rota/duties` needs `circle_id` in the body. `201 Created` with the duty; a re-run is described below.
- `GET /rota/duties?circle_id=412` lists a circle's duties, ordered by `position` (the order the app shows them in).
  Without `circle_id` it lists the duties of every circle of the community whose rota the admin can read, one circle
  after another, each circle's in `position` order. Answers
  `{"data": [<duty>, …], "next_page": null}`, 100 per page.
- `GET /rota/duties/:id` reads one.
- `PATCH /rota/duties/:id` changes the fields you send, and leaves the others as they are.
- `DELETE /rota/duties/:id` deletes it.

```sh
curl "$API/rota/duties?circle_id=412" -H "Authorization: Bearer $KEY"
```

```sh
curl -X PATCH "$API/rota/duties/61" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"reminder_days": 2}'
```

> **Warning: deleting a duty deletes its shifts, its seats and its roster too.** There is no undo. **Never send a DELETE without the admin's clear yes**, saying how many shifts go with it.

```sh
curl -X DELETE "$API/rota/duties/61" -H "Authorization: Bearer $KEY"
```

Answers `204 No Content`. When someone edits a duty in the app, a later re-run of it answers
`409 changed_since_import` (below). A `PATCH` through the API still applies.

## Shifts

A shift is a duty on certain days, with a number of seats.

```json
{
  "id": 905,
  "external_id": "old-shift-2026-11-06",
  "duty_id": 61,
  "starts_on": "2026-11-06",
  "ends_on": "2026-11-06",
  "seats": 2,
  "free_seats": 1,
  "description": "Soup and bread.",
  "assignments": [
    {
      "member_id": "EveABqLyto9L",
      "name": "Karen Holm",
      "still_member": true,
      "lead": true,
      "shift_id": 905,
      "created_at": "2026-11-02T09:10:00.000Z"
    }
  ],
  "created_at": "2026-11-02T09:06:00.000Z"
}
```

| Field | Rules |
|---|---|
| `duty_id` | Required on a create: the duty it belongs to. A shift stays on the duty it was made on; neither a `PATCH` nor a re-run can move it. |
| `starts_on` | Required on a create: a plain date, `2026-11-06`. A time or a date that does not exist is `422`. |
| `ends_on` | A plain date, not before `starts_on`. **Defaults to `starts_on`**: a shift sent without an end is a one-day shift, also on a re-run that leaves it out. A `PATCH` that sends only `starts_on` keeps the stored `ends_on`. |
| `seats` | A whole number from 1 to 50. Default 1. It cannot go below the seats already taken. |
| `description` | Text of at most 500 characters. `null` clears it. |
| `external_id` | Your id for the shift on the old platform. Always send it. |

The dates are plain dates, with no time and no time zone. They may be in the past: the API has no past check, as the
admin has none in the app.

```sh
curl "$API/rota/shifts" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"duty_id": 61, "external_id": "old-shift-2026-11-06",
       "starts_on": "2026-11-06", "seats": 2,
       "description": "Soup and bread."}'
```

- `POST /rota/shifts` answers `201 Created` with the shift (seats and all). Make shifts **one by one**, each with its
  `external_id`; there is no repeat or bulk endpoint, and a run that stops halfway is simply sent again.
- `GET /rota/shifts?duty_id=61` lists a duty's shifts. `duty_id` is required. They run **oldest first**, the schedule's
  order. `?from=2026-11-01` keeps the shifts that end on or after that date, which is how to ask for "from today on".
  Answers `{"data": […], "next_page": null}`, 100 per page.
- `GET /rota/shifts/:id` reads one.
- `PATCH /rota/shifts/:id` changes the fields you send.
- `DELETE /rota/shifts/:id` deletes it, with its seats.

```sh
curl "$API/rota/shifts?duty_id=61&from=2026-11-01" -H "Authorization: Bearer $KEY"
```

```sh
curl -X PATCH "$API/rota/shifts/905" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"seats": 3}'
```

**Moving a shift's dates** (a `PATCH` or a re-run that changes `starts_on` or `ends_on`) cancels any swap request on
it that the new dates void, and tells the member who asked for it: see "Nobody is told". **Deleting a shift** deletes any
hand-over request on it at once, and tells nobody. A swap of shifts (a trade) whose other shift survives is cancelled
later by the app's own hourly clean-up, which tells the member who asked (the key's holder too, if they asked: the
clean-up knows no actor).

> **Warning: deleting a shift removes its seats for the people on it.** **Never send a DELETE without the admin's
> clear yes.** There is no undo.

```sh
curl -X DELETE "$API/rota/shifts/905" -H "Authorization: Bearer $KEY"
```

Answers `204 No Content`. `422 invalid` with `details.seats` when the seats asked for are fewer than those taken.

## The roster: who is in the draw for a duty

A duty's roster lists the **members** the app offers when someone is seated or draws a shift. Taking a person off the
roster keeps the seats they already hold.

```json
{
  "member_id": "EveABqLyto9L",
  "name": "Karen Holm",
  "still_member": true,
  "created_at": "2026-11-02T09:07:00.000Z"
}
```

```sh
curl "$API/rota/duties/61/participants" -H "Authorization: Bearer $KEY"
```

```sh
curl "$API/rota/duties/61/participants" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"member_id": "EveABqLyto9L"}'
```

```sh
curl -X DELETE "$API/rota/duties/61/participants/EveABqLyto9L" \
  -H "Authorization: Bearer $KEY"
```

- `GET` lists the roster, `{"data": […], "next_page": null}`.
- `POST` adds a person: `201 Created` with the row, or `200 OK` with the existing row when they are already on it
  (nothing changes).
- `DELETE …/participants/:member_id` takes a person off: `204 No Content`.

**Only current members of the circle can be added.** Anyone else (an unknown id, someone who has not joined, someone
who has left the circle) is `422` with the code `not_a_member` and `details.member_id`. The API never drops such a
person quietly, the way the app's roster form does: report them to the admin instead. A person who has left the circle
is still listed, with `"still_member": false` and no name, and can still be removed by their `member_id`.

## Seats: who is on a shift

A seat names a member on a shift, and may mark them as the lead. Seats are addressed by `member_id`, under the shift:

```json
{
  "member_id": "EveABqLyto9L",
  "name": "Karen Holm",
  "still_member": true,
  "lead": true,
  "shift_id": 905,
  "created_at": "2026-11-02T09:10:00.000Z"
}
```

```sh
curl "$API/rota/shifts/905/assignments" -H "Authorization: Bearer $KEY"
```

```sh
curl "$API/rota/shifts/905/assignments" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"member_id": "EveABqLyto9L", "lead": true}'
```

```sh
curl -X PATCH "$API/rota/shifts/905/assignments/EveABqLyto9L" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"lead": false}'
```

```sh
curl -X DELETE "$API/rota/shifts/905/assignments/EveABqLyto9L" \
  -H "Authorization: Bearer $KEY"
```

- `GET …/assignments` lists the seats and `GET …/assignments/:member_id` reads one.
- `POST` seats a person: `201 Created` with the seat. Someone who already has a seat on the shift answers `200 OK`
  with it, and nothing changes and nobody is told.
- `PATCH` sets `lead` on a seat that exists (`404 not_found` otherwise). `lead` is required and is the only field.
- `DELETE` takes the person off the shift: `204 No Content`.

The rules:

- **Seat only real members.** An unknown id or a person who is not a current member of the circle is
  `422 not_a_member` (`details.member_id`). A `PATCH` with `"lead": true` on the seat of someone who has since left
  is the same `422 not_a_member`.
- **A full shift** answers `422 no_free_seat`. Raise the shift's `seats` first. The last seat goes to one caller only.
- **One lead.** `"lead": true` on a create or `PATCH` moves the lead to that person. `"lead": false` on the lead leaves
  the shift with no lead; on anyone else it changes nothing. A `PATCH` without `lead` is `422 invalid`.
- **A lead change never cancels a swap request.** A seat that is added or removed can (see below).
- **People who left.** A seat whose person has left the circle is listed with `"still_member": false`, and can be
  removed by `member_id`. Its lead mark stays unless it was theirs.

## Re-runs

Duties and shifts have an `external_id`, and follow [Re-runs and `external_id`](start.md#re-runs-and-external_id) like
everything else:

| Answer | Meaning |
|---|---|
| `201 Created` | New. |
| `200 OK` | Already imported. It was updated with what you send and keeps its id. A shift sent without `ends_on` gets `ends_on` = `starts_on` again. |
| `409 record_deleted` | The duty or shift was deleted since (also when the rota was uninstalled). It is never brought back. Skip it and report it. |
| `409 changed_since_import` | Someone edited that duty or shift in the app since your last write (an admin's edit, or a member changing its description). It was not overwritten. Skip it and report it. |
| `409 conflict` | The same create arrived twice at once. Send it again, once. |
| `403 forbidden` / `404 not_found` | The admin can no longer run the rota there, or the rota is not live any more. Skip it and report it. |

Seats and roster rows never make a duty or a shift "changed": the question is only whether a person edited the duty or
the shift itself.

**Roster rows and seats have no `external_id`.** Their natural key is the pair: a person on a duty's roster, a person
on a shift. Sending the same one again answers `200 OK` with the row that exists, and changes nothing (a `lead` sent
with it is ignored, use `PATCH`). A run that stops halfway is sent again from the start.

## Who may do what

| What | Needs |
|---|---|
| Install the rota | The right to install extensions on the circle: its owner or admin, or an owner or admin of the community. |
| Read it (the duties, shifts, roster and seats) | Being a member of the circle, as in the app. |
| Run it (every write on this page) | Being an admin of the circle, as in the app. |

An organisation admin who is not in a sub-circle can install its rota (and ask `GET /circles/:id/rota`), but
`can_manage` is `false` and they can neither read nor run it until they join the sub-circle. The key never does more
than the admin could in the app.

## Nobody is told

Every rota write is **silent**: installing, creating, changing and deleting duties and shifts, roster rows, and seats
make no bell, no push message and no email for anyone, and nothing in a daily summary. There are three exceptions, all
the app's own behaviour:

- **`notify: true` on a seat create** sends the app's "You are on: <duty>" notice to the person just seated, as when
  an admin seats someone in the app. Never to the key's holder, and never for a seat that already existed (`200`).
  Send it only when the admin has asked for people to be told. Without `notify`, the create is silent. The notice
  goes only to a member of the circle; the API refuses anything else anyway.
- **A reseat or a date move that cancels a person's swap request tells them.** If a member had asked to swap a shift,
  and you remove a seat, add one that voids the request, or move the shift's dates, the request lapses and the app's
  usual urgent notice goes to the member who made it, as the app does. Not to the key's holder about their own
  request, and a lead change never does it. This is the only notice a write sends without `notify`, and it cannot be
  switched off: check with the admin before reseating or moving a shift people have asked to swap.
- **Deleting a shift** deletes a hand-over request on it at once, and tells nobody. Only a pending swap of shifts (a
  trade) whose other shift survives lapses later, when the app's hourly clean-up tells the member who asked (with no
  actor, so the key's holder too if they asked).

Reminders work as normal. A seat on a **future** shift gets the duty's usual reminder (`reminder_days` before it) once,
to the person seated, according to their own settings. That is the rota working, not something an import sends. A seat
on a **past** shift never gets a reminder: its time is over, so importing history tells no one.
