# Comments

Comments are what people write under a post or an event, and the replies to them. These endpoints move the comments 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. Comments work like [Posts](posts.md) and
[Events](events.md), and this page says where they differ.

Import the posts and events first. A comment goes on a post or an event that already exists in Zen Social, and a reply
goes on a comment that already exists, so a move runs in this order: posts and events, then comments, parents before
their replies. Keep your own list of the `id` each `external_id` was given.

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

- Comments on posts and on events, replies included.
- **Not in the API:** pictures on comments (the app's comment form has none either) and comments on newsletter
  editions. A comment on an edition is not listed, and `GET`, `PATCH` and `DELETE` answer `404 not_found` for it.

## The comment object

```json
{
  "id": 9201,
  "external_id": "fb-comment-8001",
  "post_id": 9001,
  "event_id": null,
  "parent_id": null,
  "agenda_item_id": null,
  "author": {"id": "EveABqLyto9L", "name": "Karen Holm"},
  "content": "I can bring basil seedlings on Saturday.",
  "hidden": false,
  "deleted": false,
  "created_at": "2023-04-15T12:30:00.000Z",
  "edited_at": null
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | number | The comment's id. Use it in `/comments/:id`, and as `parent_id` for a reply. |
| `external_id` | text or `null` | Your id from the old platform, when the comment was created through the API with one. `null` for every other comment. |
| `post_id` | number or `null` | The post the comment is on. |
| `event_id` | number or `null` | The event the comment is on. Exactly one of `post_id` and `event_id` is set. A reply carries the post or event of the whole thread, however deep it is. |
| `parent_id` | number or `null` | The comment this one replies to. `null` for a comment directly on the post or event. |
| `agenda_item_id` | number or `null` | For a comment on a meeting: the agenda item it was written under, in the app. A reply carries the item of the comment it replies to, also when you create it here. `null` for a comment in the event's own thread, and for every comment on a post. The API cannot put a comment under an item. |
| `author` | object or `null` | Who wrote it, in the same two shapes as a post's author (below). `null` for a hidden comment. |
| `content` | text | The text, as plain text. Empty (`""`) for a deleted or a hidden comment. |
| `hidden` | true or false | `true` when the comment is the app's placeholder for you: see "Hidden authors" below. |
| `deleted` | true or false | `true` for a comment that has been deleted: see "Deleted comments" below. |
| `created_at` | time | When it was written: the original date for an imported comment. |
| `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 comment was created without an `external_id` of its own. See
  [Authors and claims](authors.md).

Comments go with the account of their author when it is deleted. The one kept comment is on a meeting's agenda: it shows
`"id": null` and the name the account had, or `"name": null` when it had none.

There are no email addresses and no picture addresses.

## List them: `GET /comments`

Comments are listed for one post or one event at a time. Give `post_id` or `event_id`, and not both:

```sh
curl "$API/comments?post_id=9001" -H "Authorization: Bearer $KEY"
```

```sh
curl "$API/comments?event_id=9101" -H "Authorization: Bearer $KEY"
```

Answers `200 OK` with `{"data": [<comment>, …], "next_page": null}`, 100 per page (`?page=2` for the next). The
newest comment comes first, and comments with the same time by their id, highest first. All the comments of the post or
event come in one list, replies included, **not** nested: use `parent_id` to build the thread. A reply to a comment on
another page is on the same list, so read every page before you nest.

A comment is listed exactly as the admin would see it in the app, and nothing more:

- `404 not_found` when the post or event does not exist, is not in one of this community's circles, has been deleted,
  or is one the admin could not see in the app. (Here it is never `403`.)
- **Deleted comments are in the list** (below).
- **Comments of authors the admin has blocked, or who have blocked the admin, are in the list as placeholders** (below).

`422 invalid` when neither `post_id` nor `event_id` is given, or both.

### Deleted comments

A deleted comment stays in the list as a tombstone: `"deleted": true` and `"content": ""`. Its `id`, `parent_id`,
dates and author stay, so its replies still have a place in the thread. `GET /comments/:id` answers `200 OK` with the
tombstone too.

### Hidden authors

When the admin has blocked the author, or the author has blocked the admin, the app shows the comment as a grey
placeholder, and keeps its replies. The API gives the same placeholder: `"hidden": true`, `"content": ""` and
`"author": null`, with the rest as it is. A comment that is both hidden and deleted also has `"author": null`.

The placeholder follows the admin who made the key: another admin, with no block, sees the comment itself.

A block does not stop a `PATCH` or a `DELETE`: the rules in "Who may change which comment" apply as without it. The
`PATCH` changes the text, and answers with the placeholder (`"content": ""`), not with the text you sent.

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

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

Answers `200 OK` with the comment, also when it is deleted or hidden. `404 not_found` when it is not on a post or event
in one of this community's circles, or is on one that has been deleted or that the admin could not see in the app.

## Create one: `POST /comments`

The first example is a comment on a post under a member. The second is a reply to it, under a name from the old
platform:

```sh
curl "$API/comments" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-comment-8001", "post_id": 9001, "author_id": "EveABqLyto9L", "created_at": "2023-04-15T12:30:00Z", "content": "I can bring basil seedlings on Saturday."}'
```

```sh
curl "$API/comments" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-comment-8002", "post_id": 9001, "parent_id": 9201, "author": {"external_id": "fb-user-1002", "name": "Ole Berg"}, "created_at": "2023-04-15T18:05:00Z", "content": "Saturday is fine. I will keep the table free."}'
```

A comment on an event takes `event_id` instead of `post_id`:

```sh
curl "$API/comments" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-comment-8003", "event_id": 9101, "author_id": "EveABqLyto9L", "created_at": "2026-10-21T09:00:00Z", "content": "Is there parking near the garden hut?"}'
```

| Field | Rules |
|---|---|
| `post_id` or `event_id` | Required: exactly one of them, the id of the post or event the comment goes on. It must be in a circle of this community that the admin can see, and the admin must be able to comment in **every** circle it is in: `403 forbidden` otherwise. `404 not_found` for one that does not exist, is in no circle of this community, is deleted, or that the admin cannot see. |
| `content` | Required: plain text, not empty. The same rules as a post's text: see [The text](posts.md#the-text-content). Comments have no word limit. |
| `parent_id` | The id of the comment this one replies to. Optional: without it the comment goes directly on the post or event. See "Replies". |
| `external_id` | Your id for this comment on the old platform, as text or a number, at most 200 characters. Always send it: re-runs, changing the comment later and handing it back to its author all depend on it (see "Who may change which comment"). |
| `author_id` | A member's `id` from [Members](members.md). They must be a member of every circle the post or event is in. |
| `author` | `{"external_id": "…", "name": "…"}` for someone who has not joined. The same rules as for a post: see [Who wrote it](posts.md#who-wrote-it-author_id-or-author). |
| `created_at` | The original date and time, ISO 8601, in the past and no earlier than 1 January 1990. The same rules as for a post: see [The date](posts.md#the-date-created_at). Default: now. |
| `notify` | `true` to tell people about the comment the way the app would. Default `false`. See "Telling people". |

With neither `author_id` nor `author`, the admin who made the key is the author. Fields the API does not know are
ignored without an error, **except** `images` and `image_urls`: comments take no pictures, so those are refused (below).

Answers:

- `201 Created` with the new comment.
- `200 OK`, `409 record_deleted`, `409 changed_since_import`, `409 conflict` or `403 forbidden` when the `external_id`
  was imported before: see "Re-runs" below.
- `403 forbidden`: the admin cannot comment in one of the circles of the post or event.
- `404 not_found`: the post or event is not found (above), also when it has been deleted.
- `422 invalid`: a value is not allowed; `details` names each field and why, for example
  `{"content": ["is required"]}`. Nothing was made. This includes `images` and `image_urls`
  (`{"images": ["comments take no images"]}`), and a `parent_id` that cannot be used (below).
- `422 reply_to_another_thread`, `422 reply_too_deep`, `422 reply_to_deleted` and `422 not_accepting_comments`: see
  "Replies". Nothing was made.
- `422 notify_needs_member_author`: see "Telling people". Nothing was made.

The checks run in this order, and the first that fails answers: `422` for a malformed `external_id`, then a re-run if
the `external_id` is known (it answers as in "Re-runs", and the checks below are not made), then `422` for a missing,
doubled or malformed `post_id` or `event_id`, `404` for the post or event, `422 not_accepting_comments` (a cancelled
event), `403` for a circle the admin cannot comment in, then the other `422` answers. On a cancelled event, an admin who also cannot comment gets the `422`,
not the `403`.

### Replies

Send `parent_id` to make a reply. The reply goes in the thread of its parent and shows under it. The rules are those
of the app's Reply link:

- The parent must be on the **same post or event**. A parent on another one is
  `422 {"error":"reply_to_another_thread"}`.
- The parent must **not be deleted**: `422 {"error":"reply_to_deleted"}`. A reply to a comment that has replies of its
  own is fine.
- Replies nest **at most 9 levels** deep. A comment directly on the post or event is level 0, its reply level 1, and so
  on down to level 9. A reply to a comment that is already at level 9 is `422 {"error":"reply_too_deep"}`. If the old
  platform allows deeper threads, attach the deepest replies to the last comment that can take them, and tell the
  admin.
- The post or event must **still take comments**: a **cancelled event** does not, and the answer is
  `422 {"error":"not_accepting_comments"}`. A comment that is not a reply gets the same answer. A deleted post or event
  is not found at all: `404 not_found`.
- A `parent_id` that is not an id of a comment, **or is one the admin cannot read** (it does not exist, it is in another
  community, it is on a post or event the admin cannot see), answers `422 invalid` with `details.parent_id`. The two
  causes give exactly the same answer on purpose: the API does not tell you which ids exist elsewhere.

You give the post or event as well as the parent. The parent has to be a comment on that same post or event.

### No pictures

`images` and `image_urls` are refused with `422 invalid`, under the field that was sent. They are not ignored, so a
comment is never moved without its picture by accident. If you decide to move such a comment without the picture, leave
the two fields out, and tell the admin which comments lost a picture.

### Telling people: `notify`

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

`"notify": true` makes the create announce the comment exactly as the app does when a member comments: notifications
and push messages, and lines in daily summaries, each according to people's own notification settings. Send it only
when the admin has asked for people to be told, and never for imported history. It announces the comment once, when it
is created; a re-run never announces it again.

A comment 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"}`. Its author also needs
a complete profile, as in the app (`422 invalid`, with the reason under `details.base`).

## Re-runs

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

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

There is no `409 in_edition` for comments: a newsletter edition does not lock a comment.

A re-run changes only `content`, and only when it is sent. The post or event, the parent, the author, `created_at`
and `notify` are ignored: a re-run does not even check them, and never tells anyone. To change any of those, delete the
comment (with the admin's yes) and create it under a new `external_id`.

**An edit in the app counts, even a quiet one.** When a person opens an imported comment in the editor and saves it
without changing a word, that is an edit too: pressing Save is what counts, not whether the text changed. The next
re-run answers `409 changed_since_import` for it, and the comment stays as the person left it.

**A post that was turned into an event in the app keeps its comments.** A re-run of one of its comments answers
`200 OK`, and the comment now has `event_id` set and `post_id` `null`. A re-run ignores the `post_id` or `event_id` you
send.

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

Send `content`, with the same rules as for a create. Nothing else can be changed: other fields are ignored. Without
`content` the answer is `200 OK` and nothing changes.

```sh
curl -X PATCH "$API/comments/9201" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"content": "I can bring basil and chive seedlings on Saturday."}'
```

**Before a `PATCH` of a comment whose author is now a member** (its `author` has an `id`), **or whose `edited_at` is
not `null`**, show the admin the change (the comment 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 comment, `422 invalid` with the reasons (an empty `content`, or `images`; and, for a comment
not created through the API with an `external_id`, an author who has not completed their profile in the app, under
`details.base`), `403 forbidden` when the admin may not change this comment (see "Who may change which comment"), or
`404 not_found`. **A deleted comment answers `404 not_found`**: it cannot be changed, and it is not brought back.

A change through the API is not an edit by a person: the comment keeps its date and shows no "(Edited)" mark, and nobody
is told. A later re-run of its create still updates it. A comment 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`.

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

> **Warning: this deletes the comment for everyone.** It is replaced by a deleted comment in the thread. 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 comment it is (its author, date and the start of
its text) and wait for their answer. **Only this comment is deleted: its replies stay**, under the deleted comment, as
in the app.

```sh
curl -X DELETE "$API/comments/9201" -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. **A second `DELETE` of the same comment answers `404 not_found`.** A later create with its `external_id`
answers `409 record_deleted`. Nobody is told.

## Who may change which comment

**Comments you imported:** a comment 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 comment in
every circle of its post or event. 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.

**Every other comment** follows the app's rules, as if the admin did it in the app: only its author can change it
(the admin's own comments, and comments created through the API without an `external_id` and without an author), and
its author or an owner or admin of one of the circles of its post or event can delete it. A comment created without an
`external_id` under **another member's `author_id`** or under a **name** from the old platform has no record in the API.
You cannot `PATCH` it afterwards (`403 forbidden`), and you can delete it only with the rights the app gives: as its
author, or as an owner or admin of one of the circles. A comment under a name can never be claimed either. **Send an
`external_id` for every comment you may want to change later.**

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

## Nobody is told

Creating, re-running, changing, deleting and claiming comments tells no member anything, unless a create sends
`"notify": true`. Imported comments are not announced later either, and the post or event they are on does not move up
in the circle's activity: a comment you add to an old post leaves the post where it was in the feed.
