---
title: Коментарі до задач
description: Створення коментарів, згадки користувачів, реакції, сповіщення в Slack і курсорний фід коментарів.
---

Документ описує, як працюють коментарі до задач на бекенді kumquat: створення,
згадки користувачів, реакції, сповіщення в Slack і курсорний фід коментарів. Поля колекцій — у
[Mongo-схемі](/backend/db-schema).

Основні файли:

- `packages/kumquat-comments/src/kumquat_comments/models.py` — документ `Comment`.
- `packages/kumquat-comments/src/kumquat_comments/service.py` — `CommentsService`.
- `packages/kumquat-comments/src/kumquat_comments/repository.py` — доступ до Mongo.
- `kumquat_api/routes/comments.py` — усі ендпоінти коментарів.
- `kumquat_api/routes/tasks.py` — `GET /tasks/{id}/history`, історія переходів статусів.
- `kumquat_api/worker/tasks.py` — Celery-задача сповіщення про згадку.
- `kumquat_api/live.py` — change stream, з якого сторінка дізнається про чужий коментар.


## Ендпоінти

| Метод | Шлях | Призначення |
|---|---|---|
| `POST` | `/api/v1/tasks/{task_id}/comments` | створити коментар |
| `PATCH` | `/api/v1/tasks/{task_id}/comments/{comment_id}` | відредагувати коментар |
| `DELETE` | `/api/v1/tasks/{task_id}/comments/{comment_id}` | видалити коментар |
| `PUT` | `/api/v1/tasks/{task_id}/comments/{comment_id}/reactions/{emoji}` | поставити реакцію |
| `DELETE` | `/api/v1/tasks/{task_id}/comments/{comment_id}/reactions/{emoji}` | зняти свою реакцію |
| `GET` | `/api/v1/tasks/{task_id}/comments` | фід коментарів задачі |


## Життєвий цикл коментаря

1. Клієнт надсилає `body` і, за потреби, `mentioned_user_ids`.
2. `CommentsService.create_comment` перевіряє, що задача існує — інакше `404`.
3. Резолвиться список згадок (див. нижче).
4. Документ пишеться в `comments`; `author_id` береться з перевіреного Clerk JWT, а не з тіла
   запиту.
5. На задачі ставиться позначка про останній коментар — див. «Живі оновлення».
6. На кожного зі згаданих користувачів у чергу `io` ставиться окрема Celery-задача. Постановка в
   чергу відбувається вже після запису коментаря, тож збій сповіщення не скасовує сам коментар.

## Редагування

`PATCH /tasks/{task_id}/comments/{comment_id}` — **лише автор**, адміну редагувати чуже не можна
(на відміну від видалення). Тіло проходить ту саму нормалізацію, що й при створенні, бо валідатор
живе на моделі, а не на вхідній схемі. Проставляється `updated_at`; `created_at` не змінюється,
тож правка не рухає коментар у стрічці — фід сортується саме за часом створення.

Згадки перераховуються повністю: список у документі завжди відповідає останній версії тексту. У
чергу потрапляють **тільки ті, кого додали цією правкою** — хто вже був згаданий, повторного
сповіщення не отримує, а хто зник із тексту, не отримує нічого.


## Видалення

Видалення м'яке: `deleted_at` замість вилучення документа. Видаляти може автор або адмін.

Видалений коментар лишається у стрічці, щоб UI показав, що на цьому місці щось було. Тіло і
список згадок у відповіді порожні — текст лишається в БД для аудиту, але назовні не віддається.
Повторне видалення чи спроба відредагувати видалений коментар дають `404`.


## Згадки

`mentioned_user_ids` приходять від фронтенду — це результат автокомпліту, який підставляє готові
Clerk id. Бекенд текст коментаря не парсить: людина пише `@Іван`, а не `@user_2abc...`, тож
розбір рядка все одно нічого корисного не дав би.

Сервіс над отриманим списком робить три речі: викидає автора (сам себе згадати не можна),
дедуплікує і перевіряє кожен id у довіднику `users`. Неіснуючі id мовчки відкидаються — у
збереженому коментарі лишаються тільки ті, кого справді можна сповістити. Список зберігається
відсортованим, щоб UI міг малювати чипи `@Ім'я` без повторного розбору тексту.


## Реакції

Реакція — емодзі, який будь-який автентифікований читач ставить на коментар, у тому числі на
власний. Дозволу тут захищати нічого: перевірка одна — коментар живий.

`PUT` ставить, `DELETE` знімає, обидва ідемпотентні: емодзі плюс той, хто питає, — це вся суть
операції, тож повторний `PUT` каже те саме, а `DELETE` реакції, якої не було, не помилка. Обидва
повертають коментар цілком, з уже перерахованою мапою `reactions`. Реакція на видалений або
неіснуючий коментар — `404`; емодзі поза набором — `422`.

Одна людина може тримати кілька різних емодзі на одному коментарі.

**Набір закритий** — `ReactionEmoji` у `kumquat_comments/enums.py`, 30 емодзі. Причина в тому, що
емодзі тут це **ім'я поля** в мапі `reactions`: вільний текст дозволив би клієнту наплодити
скільки завгодно ключів на документі, який читає весь фід. Закритий набір ще й обмежує документ —
максимум по одному ключу на члена енама, тож окремий ліміт не потрібен.

Фронтенд дзеркалить цей список у `core/comments/reactions.ts` (порядок сітки пікера — рішення
дизайну, а не порядок енама); його власний тест звіряє два набори через згенеровану схему, тож
доданий член енама ламає тест, поки емодзі не отримає місця в сітці.

**Записи — атомарні операції над масивом**, `$addToSet` і `$pull` на `reactions.<emoji>`, а не
read-modify-write: двоє, що реагують одночасно, інакше перезаписали б одне одного. Коли останній
реактор іде, ключ **видаляється** (`$unset`), а не лишається порожнім масивом — інакше UI намалював
би чіп із нулем. `$unset` іде під умовою `$size: 0`, щоб реакція, додана між `$pull` і ним, не
загубилася.

Реакції **не пишуться в аудит** і **не рухають `updated_at`**: реакція не є версією коментаря, а
`updated_at` — це те, що фід показує як «(edited)». Toggle туди-сюди інакше засмічував би журнал.
Задачу реакція позначає так само, як і сам коментар — див. «Живі оновлення».

Реактори сайдлоадяться в `included.users` разом з авторами і згаданими: чіп у тултипі називає
людей за числом, і це не обов'язково автор чи хтось зі згаданих.


## Сповіщення

Про реакцію нікого не сповіщають: DM на кожен 👍 швидко став би спамом і знецінив сповіщення про
згадки.

Задача `tasks.io.comment_mention` (черга `io`) на кожну згадку робить одну спробу: знаходить email
отримувача в дзеркалі `users` і надсилає Slack DM з назвою задачі.

Результат спроби пишеться в `audit__logs` рядком `comment.mention_notified` зі статусом у
`metadata.status`:

- `sent` — email знайдено і DM надіслано;
- `skipped_no_email` — користувача або його email не знайдено, DM не надсилався.

Спроба доставки нічого локально не змінює, тож рядок несе `changes = null`, а `entity_id` — це
`comment_id`. Окремої колекції `mention_notification_logs` більше немає.

`sent` означає «спробу зроблено», а не «Slack підтвердив доставку»: `SlackNotifier` ковтає помилки
Slack API і лише логує warning, тож на цьому рівні фактичну доставку підтвердити неможливо.

`task_id` передається в задачу аргументом (потрібна назва задачі для тексту DM), але в лог не
пишеться — задача і так досяжна через `comment_id`.


## Права на видалення

Видалити коментар може його автор або користувач з роллю `admin`. Будь-хто інший отримує `403`,
неіснуючий коментар — `404`. Видалення просте: один документ, без каскадів.


## Обмеження та рішення

**Нормалізація `body`.** Перед збереженням текст проходить `normalize_body` у моделі `Comment`:

1. NFKC — щоб `Ｈｅｌｌｏ` і `Hello` не були різними рядками;
2. видалення невидимих символів (Unicode-категорії `Cc`, `Cf`, `Zl`, `Zp`, окрім `\t` і `\n`) —
   це zero-width пробіли, м'який дефіс, BOM і bidi-оверрайди, які нічого не показують, але
   рахуються в довжині й дозволяють візуально підмінити текст;
3. стиснення прогонів із трьох і більше переводів рядка до одного порожнього;
4. обрізання країв.

Якщо після цього рядок порожній — `422`.

**2000 символів, і чому ліміт стоїть двічі.** `CommentCreate` має `Field(max_length=2000)` — це
дешева рання відсіч на сирому запиті. Але остаточну перевірку робить `normalize_body`, бо NFKC
здатен **подовжити** рядок: `ﬁ` розкладається на `fi`, `℡` на `TEL`. 1500 лігатур пройдуть вхідний
ліміт і перетворяться на 3000 символів, тож без другої перевірки в базу потрапив би текст більший
за оголошений максимум.

**`author_id` — рядок, а не `ObjectId`.** `users._id` у нас сам є Clerk-рядком (`user_...`), бо
колекція `users` — дзеркало Clerk. Тип `PyObjectId` відхилив би такий запис, тому тут і в
`mentioned_user_ids` використовується `str`. Той самий підхід уже в `TaskTransition.actor_id` і
`Task.task_owner_id`.

**Тредів немає.** Коментарі плоскі. Відповіді були реалізовані й зберігалися, але фід повертає
хронологічний список і UI їх ніде не показував, тож поле прибрано, щоб не тягнути мертву схему.


## Лічильник на картці канбану

Картка на дошці показує кількість коментарів — бейдж навпроти рядка з `count`. Число не рахується
на льоту: воно лежить на самій задачі, у полі `comment_count`, і `TaskCard` віддає його разом з
усіма іншими полями проєкції.

Чому саме так. Дошка відкривається одним запитом на всі колонки (`GET /tasks/board/columns`) — це
близько 70 карток, і перечитує вона себе на кожен кадр change stream. Порахувати коментарі під
кожну картку означало б або `count_documents` на задачу, або зайвий `$group` по `comments` на
кожен такий запит. Збережене число коштує нуль запитів.

Пише його той самий запис, який коментар і так робить по задачі —
`TasksRepository.touch_last_comment` додає `$inc` до вже наявного `$set` позначки, тож ані
створення коментаря, ані його видалення не отримують додаткового раунд-трипу. `$inc`, а не
збережений результат `count_documents`: двоє, що пишуть одночасно, інакше записали б кожен той
підсумок, який прочитав сам.

Рухають лічильник тільки два записи: створення (`+1`) і видалення (`-1`). Правка і реакція
позначають задачу, але числа не торкаються. Повторне видалення теж — `soft_delete` повідомляє, що
не змінив нічого, і до лічильника справа не доходить.

**Лічильник рахує живі коментарі**, а `total` у фіді — усі, включно з видаленими: у стрічці
надгробок лишається, щоб UI показав пропуск, а на дошці бейдж «1» над стрічкою без жодного тексту
збивав би з пантелику. Видалені коментарі згодом і зникають з колекції по TTL, тож число, що їх
рахує, все одно поїхало б.

Поле не має `ge=0`: збитий лічильник має віддатися хибним числом, а не завалити валідацією всю
відповідь дошки. Лагодить його `kumquat-cli backfill-comment-counts` — він же й засіює задачі, які
вже мали коментарі на момент появи поля. Команда ідемпотентна (другий прогін не пише нічого) і
робить один прохід по `comments` плюс по одному `update_many` на кожне окреме значення, а не запис
на задачу. `--dry-run` показує, скільки задач вона б виправила.

Живих оновлень бейдж не має — див. «Живі оновлення».


## Фід коментарів

`GET /tasks/{id}/comments` віддає коментарі задачі від найновішого, keyset-курсором — той самий
двофазний механізм, що й у creatives і tasks ([пагінація](/backend/range-based-cursor-pagination)).
`total` приходить лише на першій сторінці (без `cursor`), далі клієнт іде за `meta.next_cursor`,
доки той не стане `null`. Сортування — за `created_at`, тож правка коментаря (`updated_at`) не
рухає його у фіді.

Зміни статусів — окремий ендпоінт, `GET /tasks/{id}/history`: він читає рядки
`task.status_changed` з `audit__logs` і віддає `TaskTransition` тим самим курсором.

**Раніше це був один ендпоінт** — `GET /activity`, що зливав обидва джерела в один список з
`limit`/`offset`. UI показує їх у двох вкладках, тож змішана сторінка означала, що вкладка
«Коментарі» могла отримати один рядок із десяти, а `total` рахував і те, і те. Двоколекційне
злиття в застосунку зникло разом із ним: кожен ендпоінт тепер читає рівно одну колекцію одним
індексованим range-скануванням.

Обидва фіди сайдлоадять людей у `included.users` — авторів і згаданих для коментарів, акторів
переходів для історії.


## Живі оновлення

Відкрита сторінка задачі бачить чужий коментар без перезавантаження. Окремого стріму для цього
немає: будь-який запис коментаря (створення, правка, видалення, реакція) додатково ставить на **задачі**
позначку `__last_comment_id` + `__last_comment_at` (`TasksRepository.touch_last_comment`), а
колекцію задач і так читає change stream із `kumquat_api/live.py`. Кадр `task_changed`, що з нього
виходить, несе `last_comment_id` і `last_comment_at`, і фронтенд перечитує на них тільки фід.

Чому саме так:

- **Другий change stream на `comments`** означав би другого читача на кожну репліку заради
  повідомлення про ту саму задачу.
- **Позначка не рухає `updated_at`** і не пише рядка в аудит. Коментар не є редагуванням задачі:
  форма, яка перечитала б свої поля, підмінила б читачеві те, на що він дивиться, а історія
  показала б зміну, якої ніхто не робив. Тому ж поля починаються з `__` —
  [аудит](/backend/audit-log) такі пропускає.
- **Час у позначці обов'язковий.** Повторна правка того самого коментаря записала б незмінний
  `__last_comment_id`, а це для Mongo no-op, якого не буде в oplog — і сторінка нічого б не почула.
- **На дошку такий кадр не йде** (`BoardSubscribers.broadcast`). Єдине, що коментар на картці
  рухає, — бейдж `comment_count`, а кожен кадр коштує клієнтові перечитування всіх колонок. Тому
  бейдж наздоганяє себе на наступному читанні дошки (перехід, зміна фільтра, будь-яка інша зміна
  задачі): картку ніхто не сортує за лічильником, а альтернатива — кожна відкрита дошка
  перечитується на кожен коментар у системі.

Що з цього видно читачеві (`modules/tasks/model/comment-arrivals.ts` і `activity-feed.tsx`):

- Якщо він унизу стрічки — новий коментар просто з'їжджає у поле зору й на кілька секунд
  підсвічується.
- Якщо він відгорнув стрічку вгору — унизу з'являється пігулка «N new comments», яка повертає його
  до кінця; підсвітка тримається доти, доки він туди не дійде, а не з моменту, коли коментар прийшов.
- Власний коментар не рахується і не підсвічується — його id запам'ятовується з відповіді на
  `POST`, і так само знімається з уже нарахованих, якщо кадр устиг прийти раніше за відповідь.
- «Нове» — це позиція, а не час: стрічка сортована від найновішого, тож нове — усе, що вище
  найновішого відомого id. Порівняння множин id рахувало б новими ще й старі сторінки, які щойно
  дозавантажились.
- Реакція перечитує фід, але **не рахується новим коментарем**: жодного нового id не приїхало, тож
  пігулка «1 new comment» була б неправдою. Позначка ставиться навіть тоді, коли toggle нічого не
  змінив (повторний `PUT`) — альтернатива це другий запит, аби дізнатися, чи це був повтор.

Свою реакцію читач бачить одразу, до відповіді сервера (`toggleReaction` у
`core/comments/reactions.ts` повторює логіку `$addToSet`/`$pull`, включно з видаленням емодзі без
реакторів). Реакція — жест на один тап: чіп, який чекає на round trip, читається як клік, що не
влучив, і читач тапає вдруге. Перечитування фіду відбувається в обох випадках — і після помилки,
щоб чіп не брехав, і після успіху, щоб підхопити чужу реакцію, яка приїхала тим часом.
