Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

Коментарі до задач

Створення коментарів, згадки користувачів, реакції, сповіщення в 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 фід коментарів задачі

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

  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 і 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 розкладається на 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, читається як клік, що не влучив, і читач тапає вдруге. Перечитування фіду відбувається в обох випадках — і після помилки, щоб чіп не брехав, і після успіху, щоб підхопити чужу реакцію, яка приїхала тим часом.

Was this page helpful?