Коментарі до задач
Створення коментарів, згадки користувачів, реакції, сповіщення в Slack і курсорний фід коментарів.
Документ описує, як працюють коментарі до задач на бекенді kumquat: створення, згадки користувачів, реакції, сповіщення в Slack і курсорний фід коментарів. Поля колекцій — у Mongo-схемі.
Основні файли:
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 |
фід коментарів задачі |
Життєвий цикл коментаря
- Клієнт надсилає
bodyі, за потреби,mentioned_user_ids. CommentsService.create_commentперевіряє, що задача існує — інакше404.- Резолвиться список згадок (див. нижче).
- Документ пишеться в
comments;author_idбереться з перевіреного Clerk JWT, а не з тіла запиту. - На задачі ставиться позначка про останній коментар — див. «Живі оновлення».
- На кожного зі згаданих користувачів у чергу
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:
- NFKC — щоб
HelloіHelloне були різними рядками; - видалення невидимих символів (Unicode-категорії
Cc,Cf,Zl,Zp, окрім\tі\n) — це zero-width пробіли, м’який дефіс, BOM і bidi-оверрайди, які нічого не показують, але рахуються в довжині й дозволяють візуально підмінити текст; - стиснення прогонів із трьох і більше переводів рядка до одного порожнього;
- обрізання країв.
Якщо після цього рядок порожній — 422.
2000 символів, і чому ліміт стоїть двічі. CommentCreate має Field(max_length=2000) — це
дешева рання відсіч на сирому запиті. Але остаточну перевірку робить normalize_body, бо NFKC
здатен подовжити рядок: fi розкладається на 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 (пагінація).
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і не пише рядка в аудит. Коментар не є редагуванням задачі: форма, яка перечитала б свої поля, підмінила б читачеві те, на що він дивиться, а історія показала б зміну, якої ніхто не робив. Тому ж поля починаються з__— аудит такі пропускає. - Час у позначці обов’язковий. Повторна правка того самого коментаря записала б незмінний
__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, читається як клік, що не
влучив, і читач тапає вдруге. Перечитування фіду відбувається в обох випадках — і після помилки,
щоб чіп не брехав, і після успіху, щоб підхопити чужу реакцію, яка приїхала тим часом.