# Posts

Posts are what people write in a circle: text, with a link or pictures. These endpoints move the posts of the old
platform in, with their original dates and authors, and let you correct or remove what you imported. Read
[Start here](start.md) first: its rules apply to everything on this page.

## The post object

```json
{
  "id": 9001,
  "external_id": "fb-post-5001",
  "circle_ids": [412],
  "author": {"id": "EveABqLyto9L", "name": "Karen Holm"},
  "content": "Who has spare tomato seedlings?\n\nI can swap basil.",
  "link_url": null,
  "images": [{"id": 3307, "filename": "seedlings.jpg"}],
  "created_at": "2023-04-15T10:00:00.000Z",
  "edited_at": null
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | number | The post's id. Use it in `/posts/:id`. |
| `external_id` | text or `null` | Your id from the old platform, when the post was created through the API with one. `null` for every other post. |
| `circle_ids` | list of numbers | The circles it is in. Only this community's circles are listed. |
| `author` | object | Who wrote it (below). |
| `content` | text | The text, as plain text. |
| `link_url` | text or `null` | The address of its link preview. `null` until Zen Social has fetched the preview, and stays `null` if the preview could not be fetched. |
| `images` | list | Its pictures, as `{"id", "filename"}`. There is no picture address: the pictures belong to the circle's members, and the API does not hand them out. |
| `created_at` | time | When it was written: the original date for an imported post. |
| `edited_at` | time or `null` | When a person last edited it in the app. Changes through the API never set it. |

**`author`** is one of two shapes:

- A member: `{"id": "EveABqLyto9L", "name": "Karen Holm"}`. The `id` is the member's id from [Members](members.md).
- A name from the old platform, for someone who has no Zen Social account (yet):
  `{"id": null, "name": "Ole Berg", "external_id": "fb-user-1002"}`. The `external_id` is your own id for that person,
  as you sent it; `null` if the post was created without an `external_id` of its own. See
  [Authors and claims](authors.md).

There are no email addresses, and no picture or attachment addresses: posts are not an export.

## List them: `GET /posts`

```sh
curl "$API/posts" -H "Authorization: Bearer $KEY"
```

Answers `200 OK` with `{"data": [<post>, …], "next_page": null}`, newest first (by `created_at`), 100 per page
(`?page=2` for the next).

The list shows exactly what the admin would see in the app, and nothing more:

- only posts in circles the admin is a member of, including the community's own feed;
- in a curated circle, a plain member sees only their own posts and those published in an edition, and so does the
  admin unless they are that circle's owner, admin or editor;
- posts by people the admin has blocked, or who have blocked the admin, are left out;
- deleted posts are left out.

One circle only: add `?circle_id=`. The community's own feed is a circle here too: its id is the `id` from
`GET /organization`.

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

`404 not_found` when that circle is not one of this community's; `403 forbidden` when the admin is not a member of it.

## Read one: `GET /posts/:id`

```sh
curl "$API/posts/9001" -H "Authorization: Bearer $KEY"
```

Answers `200 OK` with the post. `404 not_found` when it is not in one of this community's circles, or has been
deleted; `403 forbidden` when the admin could not see it in the app.

## Create one: `POST /posts`

```sh
curl "$API/posts" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-post-5001", "circle_ids": [412], "author_id": "EveABqLyto9L", "created_at": "2023-04-15T10:00:00Z", "content": "Who has spare tomato seedlings?\n\nI can swap basil."}'
```

| Field | Rules |
|---|---|
| `circle_ids` | Required: a list of one or more circle ids of this community. The post appears in each of them. The admin must be able to post in every one (not just read it): `403 forbidden` otherwise, and `404 not_found` for a circle outside the community. |
| `content` | Required: plain text (below). |
| `external_id` | Your id for this post on the old platform, as text or a number, at most 200 characters. Always send it: re-runs depend on it, and so does handing the post back to its author later (see [Authors and claims](authors.md)). An empty string counts as none. |
| `author_id` | A member's `id` from [Members](members.md). See "Who wrote it". |
| `author` | `{"external_id": "…", "name": "…"}` for someone who has not joined. See "Who wrote it". |
| `created_at` | The original date and time, ISO 8601 (below). Default: now. |
| `link_url` | An address for a link preview (below). |
| `image_urls` | A list of picture addresses for Zen Social to fetch (below). |
| `images` | Picture files, in a multipart form (below). |
| `notify` | `true` to tell people about the post the way the app would. Default `false`. See "Telling people". |

Fields the API does not know are ignored without an error.

Answers:

- `201 Created` with the new post.
- `200 OK`, `409 record_deleted`, `409 changed_since_import`, `409 in_edition`, `409 conflict` or `403 forbidden`
  when the `external_id` was imported before: see "Re-runs" below.
- `403 forbidden`: the admin cannot post in one of the circles.
- `404 not_found`: one of the circles is not this community's.
- `422 invalid`: a value is not allowed; `details` names each field and why, for example
  `{"author_id": ["must be the id of a member of every circle the post goes into"]}`. Nothing was made.
- `422 notify_needs_member_author`: `"notify": true` with an `author` name. Nothing was made.

### The text: `content`

Plain text, not HTML and not Markdown: what you send is shown exactly as written.

- A blank line starts a new paragraph. A single line break stays a line break. In JSON, write them as `\n\n` and `\n`.
- Addresses starting with `http://` or `https://` become links by themselves.
- `<b>`, `**bold**` and the like are shown as typed, not as formatting.

It cannot be empty. The answer gives it back as plain text.

A circle can have a word limit for posts. Imports do not have to keep to it, since they were written elsewhere; a post
sent with `"notify": true` does, as in the app (`422`, with the reason under `details.content`).

### Who wrote it: `author_id` or `author`

Send one of these, or neither:

- **`author_id`**: the person is already a member. Use their `id` from `GET /members`. They must be a member of every
  circle in `circle_ids`, or the answer is `422` with `details.author_id`. The post is theirs, as if they had written it
  in the app.
- **`author`**: the person has not joined (yet). Send `{"external_id": "fb-user-1002", "name": "Ole Berg"}`:
  - `external_id` is your own id for that person on the old platform, as text or a number, 1 to 200 characters. A
    number comes back as text (`1002` as `"1002"`), and that text is the id in a claim's address. Use the same one for
    every post by the same person: it is how all their posts are handed to them later, in one go.
  - `name` is the name the post shows, 1 to 100 characters, after spaces at either end are trimmed.

  The post shows that name, with no profile and no link. When the person joins, you can hand the post to them: see
  [Authors and claims](authors.md).
- **Neither**: the admin who made the key is the author.

Both at once is `422` with `details.author`.

### The date: `created_at`

The post's original date and time, in ISO 8601, for example `2023-04-15T10:00:00Z` or `2023-04-15T12:00:00+02:00`.
Send the offset (or `Z` for UTC): a time without one is read as UTC. It must be in the past, and no earlier than
1 January 1990; otherwise the answer is `422` with `details.created_at`.

The post keeps that date everywhere: it sorts by it in the feeds, it does not count as new on anyone's Home page, and
its circle does not look "active just now". Without `created_at` the post is dated now.

### A link: `link_url`

An absolute `http` or `https` address, at most 2000 characters. The post shows a preview of it (title, picture,
summary), as when a member pastes a link in the app. Zen Social fetches the preview shortly after the post is saved:
until then, and when it cannot be fetched, the answer shows `"link_url": null`.

### Pictures: `images` and `image_urls`

At most 10 pictures a post, counted together. JPEG, PNG or AVIF only, judged by the file's contents, not by its name
or the type a server declares for it.

- **`image_urls`**, in a JSON body: a list of public `http` or `https` addresses. Zen Social fetches each one while
  you wait, at most 5 MB each. That is slow: send posts with many pictures one at a time.

  ```sh
  curl "$API/posts" \
    -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
    -d '{"external_id": "fb-post-5003", "circle_ids": [412], "author": {"external_id": "fb-user-1002", "name": "Ole Berg"}, "created_at": "2023-05-02T18:30:00Z", "content": "The swap table is up!", "image_urls": ["https://example.org/swap-table.jpg"]}'
  ```

- **`images`**, files in a multipart form, at most 10 MB each. The other fields go in the same form: a list as
  `circle_ids[]`, the author name as `author[external_id]` and `author[name]`.

  ```sh
  curl "$API/posts" \
    -H "Authorization: Bearer $KEY" \
    -F "external_id=fb-post-5004" -F "circle_ids[]=412" -F "author_id=EveABqLyto9L" \
    -F "created_at=2023-05-03T09:00:00Z" -F "content=Photos from Saturday" \
    -F "images[]=@saturday-1.jpg" -F "images[]=@saturday-2.jpg"
  ```

A picture that cannot be used answers `422` and nothing is saved: an address that cannot be fetched or is not a
picture under `details.image_urls`, a file of the wrong kind or too large under `details.attachments`, more than 10
under `details.images`.

The pictures are private to the members of the post's circles, as pictures posted in the app are.

### Telling people: `notify`

By default nothing tells anyone about a post you create (see [Silent by default](start.md#silent-by-default)).

`"notify": true` makes the create announce the post exactly as the app does when a member posts: notifications, push
messages and lines in daily summaries, for the circle's members, each according to their own notification settings.
Send it only when the admin has asked for people to be told, and never for imported history.

A post with `notify` needs a real member as its author (`author_id`, or neither field): a name from the old platform
cannot speak to anyone. With `author` the answer is `422 {"error":"notify_needs_member_author"}`. A post with `notify`
also keeps to the circle's word limit, and its author needs a complete profile, as in the app (`422 invalid`
otherwise).

## Re-runs

Send a create again with the same `external_id`, and the post is not made twice. The answers are those of
[Re-runs and `external_id`](start.md#re-runs-and-external_id):

| Answer | Meaning |
|---|---|
| `200 OK` | The post was updated and keeps its id. |
| `409 record_deleted` | The post was deleted (in the app or through the API), or turned into an event in the app. It is never brought back. Skip it and report it. |
| `409 changed_since_import` | Someone edited the post in the app since your last write. It was not overwritten. Skip it and report it. A comment on the post, or a claim, does not count as an edit. |
| `409 in_edition` | The post is in a newsletter edition, so its text can no longer change. Nothing was changed. Skip it and report it. |
| `409 conflict` | The same create arrived twice at once. Send it again, once. |
| `403 forbidden` | The admin can no longer post in one of the post's circles. Skip it and report it. |
| `422 invalid` | The `content` or `link_url` sent is not allowed (see "Create one"); `details` says why. Nothing was changed. |

A re-run changes only `content` and `link_url`, and only when they are sent. Everything else about the post stays as
it was first imported, and a re-run does not even check it: `circle_ids`, the author, `created_at`, pictures and
`notify` are ignored. A re-run never moves a post, never changes its date or author, never adds pictures and never
tells anyone. To change any of those, delete the post (with the admin's yes) and create it under a new `external_id`.

## Change one: `PATCH /posts/:id`

Send `content`, `link_url` or both, with the same rules as for a create. `"link_url": null` removes the link preview.
Nothing else can be changed: other fields are ignored.

```sh
curl -X PATCH "$API/posts/9001" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"content": "Who has spare tomato seedlings?\n\nI can swap basil or chives."}'
```

**Before a `PATCH` of a post whose author is now a member** (its `author` has an `id`), **or whose `edited_at` is not
`null`**, show the admin the change (the post as it is and as it will be) and wait for their yes. Those may be a
person's own words, written or corrected by them.

Answers `200 OK` with the post, `422 invalid` with the reasons, `403 forbidden` when the admin may not change this post
(see "Who may change which post"), `409 in_edition` when the post is in a newsletter edition, or `404 not_found`.

A change through the API is not an edit by a person: the post shows no "edited" mark, keeps its date, and nobody is
told. A later re-run of its create still updates it. A post someone edited in the app is different: it keeps that mark
after your `PATCH`, so a later re-run still answers `409 changed_since_import`.

A post created through the API with an `external_id` need not keep to its circles' word limit when you change it;
any other post must, as in the app.

## Delete one: `DELETE /posts/:id`

> **Warning: this deletes the post for everyone.** Its comments go with it and its pictures are removed. There is no
> undo, and a re-run never brings it back.

**Never send a DELETE without the admin's clear yes.** Tell them which post it is (its author, date and the start of
its text) and wait for their answer.

```sh
curl -X DELETE "$API/posts/9001" -H "Authorization: Bearer $KEY"
```

Answers `204 No Content`. `403 forbidden` when the admin may not delete it, `404 not_found` when it is not this
community's or is already deleted. A later create with its `external_id` answers `409 record_deleted`.

## Who may change which post

**Posts you imported:** a post created through this API **with an `external_id`** may be re-run, changed, deleted and
handed to its author through the API, whoever its author is now, as long as the admin can still post in every one of
its circles. That holds for any admin's key in this community, and after a claim has made a member its author. This is
the steward right: whoever moves the content in can still correct it.

**A post in a newsletter edition** can no longer be changed, not even under the steward right: the edition shows it as
published, and in the app not even its author can edit it. A `PATCH` or a re-run answers
`409 {"error":"in_edition"}` and changes nothing. It can still be deleted.

**Every other post** follows the app's rules, as if the admin did it in the app: only its author can change it (the
admin's own posts, and posts created through the API without an `external_id` and without an author), and not even its
author once it is in a newsletter edition (`403 forbidden`); its author or an owner or admin of one of its circles can
delete it. A post created without an `external_id` under someone else's
name or member id cannot be changed through the API afterwards: another reason to always send one.

The steward right exists only in this API. In the app, an imported post follows the usual rules: a member author can
edit it like any post of theirs; a post under a name from the old platform cannot be edited by anyone in the app, and
an owner or admin of its circle can delete it.

## Nobody is told

Creating, changing and deleting posts tells no member anything, unless a create sends `"notify": true`.

Two things to know about communities whose circles share posts with other servers on the open social web (federation):
deleting a post through the API also asks those servers to remove their copy, and changing a post through the API does
not update the copies they already have.
