From 1ff43923c9b9606f28d6eb71624b163deef9d1c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dominik=20B=C3=B6ckle?=
<43860910+dpb97@users.noreply.github.com>
Date: Wed, 21 Oct 2026 07:15:07 +0200
Subject: [PATCH 1/2] feat(comments): react to comments with emoji (core#298)
Lets users acknowledge a comment (e.g. after an @mention) without writing
a reply.
- comment_reactions persistence: migration plus fresh-install schema for
SQLite and PostgreSQL, unique per comment, user, and reaction; rows
cascade with the comment and the user.
- Fixed reaction set: thumbs_up, check, eyes, heart, laugh, hooray.
- API v2: POST /comments/{comment_id}/reactions and
DELETE /comments/{comment_id}/reactions/{reaction}, both idempotent and
gated like commenting (comment read + item.comment). Comment list, get,
update, and batch responses carry grouped reactions; approval rows never do.
- Live update: reactions publish an item change so open views reconcile.
- UI: reaction chips under each comment (own reaction highlighted, click
toggles, tooltip lists who reacted) and a picker; strings for en, de, fr,
ru, zh-CN.
---
api/openapi-v2.json | 395 ++++++++++++++++++
frontend/src/lib/api/index.js | 4 +
frontend/src/lib/api/misc.js | 10 +
.../features/items/CommentReactions.svelte | 158 +++++++
.../src/lib/features/items/Comments.svelte | 15 +
frontend/src/lib/locales/de/workspace.js | 9 +
frontend/src/lib/locales/en/workspace.js | 9 +
frontend/src/lib/locales/fr/workspace.js | 9 +
frontend/src/lib/locales/ru/workspace.js | 2 +-
frontend/src/lib/locales/zh-CN/workspace.js | 9 +
internal/database/migrations.go | 32 ++
internal/database/schema/content.sql | 14 +
internal/database/schema/content_postgres.sql | 14 +
internal/models/item.go | 12 +
internal/restapi/v2/comments.go | 104 ++++-
internal/restapi/v2/contract-metadata.json | 77 ++++
internal/restapi/v2/router.go | 3 +
internal/services/comment_reactions.go | 162 +++++++
internal/services/comment_reactions_test.go | 118 ++++++
19 files changed, 1150 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/lib/features/items/CommentReactions.svelte
create mode 100644 internal/services/comment_reactions.go
create mode 100644 internal/services/comment_reactions_test.go
diff --git a/api/openapi-v2.json b/api/openapi-v2.json
index 5b4907884..6d4b5ddd1 100644
--- a/api/openapi-v2.json
+++ b/api/openapi-v2.json
@@ -7058,6 +7058,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -13571,6 +13609,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -53530,6 +53606,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -65596,6 +65710,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -65643,6 +65795,65 @@
],
"type": "object"
},
+ "PostCommentsCommentIdReactionsPayload": {
+ "additionalProperties": false,
+ "properties": {
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "reaction"
+ ],
+ "type": "object"
+ },
+ "PostCommentsCommentIdReactionsResult": {
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "data"
+ ],
+ "type": "object"
+ },
"PostConditionSetsPayload": {
"additionalProperties": false,
"properties": {
@@ -76012,6 +76223,44 @@
"nullable": true,
"type": "integer"
},
+ "reactions": {
+ "description": "The reactions value.",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "count": {
+ "description": "The count value.",
+ "type": "integer"
+ },
+ "reaction": {
+ "description": "The reaction value.",
+ "type": "string"
+ },
+ "user_ids": {
+ "description": "The user ids value.",
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "user_names": {
+ "description": "The user names value.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "count",
+ "reaction",
+ "user_ids",
+ "user_names"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"source": {
"description": "The source value.",
"type": "string"
@@ -103670,6 +103919,152 @@
]
}
},
+ "/comments/{comment_id}/reactions": {
+ "post": {
+ "description": "Add comment reaction. The server validates resource ownership and command preconditions before persisting changes. Unknown JSON fields are rejected when the operation accepts a body.",
+ "operationId": "postCommentsCommentIdReactions",
+ "parameters": [
+ {
+ "description": "The comment identifier.",
+ "in": "path",
+ "name": "comment_id",
+ "required": true,
+ "schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostCommentsCommentIdReactionsPayload"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostCommentsCommentIdReactionsResult"
+ }
+ }
+ },
+ "description": "Add comment reaction response."
+ },
+ "400": {
+ "$ref": "#/components/responses/InvalidRequestError"
+ },
+ "401": {
+ "$ref": "#/components/responses/AuthenticationError"
+ },
+ "403": {
+ "$ref": "#/components/responses/PermissionError"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFoundError"
+ },
+ "413": {
+ "$ref": "#/components/responses/PayloadTooLargeError"
+ },
+ "415": {
+ "$ref": "#/components/responses/UnsupportedMediaTypeError"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimitError"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ },
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "summary": "Add comment reaction",
+ "tags": [
+ "Work items"
+ ],
+ "x-required-scopes": [
+ "items:write"
+ ]
+ }
+ },
+ "/comments/{comment_id}/reactions/{reaction}": {
+ "delete": {
+ "description": "Remove comment reaction. The server verifies resource ownership and deletion preconditions before removing the resource.",
+ "operationId": "deleteCommentsCommentIdReactionsReaction",
+ "parameters": [
+ {
+ "description": "The comment identifier.",
+ "in": "path",
+ "name": "comment_id",
+ "required": true,
+ "schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "description": "The reaction key: thumbs_up, check, eyes, heart, laugh, or hooray.",
+ "in": "path",
+ "name": "reaction",
+ "required": true,
+ "schema": {
+ "enum": [
+ "thumbs_up",
+ "check",
+ "eyes",
+ "heart",
+ "laugh",
+ "hooray"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Request completed successfully with no response body."
+ },
+ "400": {
+ "$ref": "#/components/responses/InvalidRequestError"
+ },
+ "401": {
+ "$ref": "#/components/responses/AuthenticationError"
+ },
+ "403": {
+ "$ref": "#/components/responses/PermissionError"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFoundError"
+ },
+ "429": {
+ "$ref": "#/components/responses/RateLimitError"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ }
+ },
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "summary": "Remove comment reaction",
+ "tags": [
+ "Work items"
+ ],
+ "x-required-scopes": [
+ "items:write"
+ ]
+ }
+ },
"/condition-sets": {
"get": {
"description": "List condition sets. Results include only resources visible to the authenticated caller; collections use their documented stable order and bounds.",
diff --git a/frontend/src/lib/api/index.js b/frontend/src/lib/api/index.js
index cff313c93..155c3873d 100644
--- a/frontend/src/lib/api/index.js
+++ b/frontend/src/lib/api/index.js
@@ -73,6 +73,7 @@ import { leave } from './leave.js';
import { logbook } from './logbook.js';
import { iterations, iterationTypes, milestoneCategories, milestones } from './milestones.js';
import {
+ addCommentReaction,
attachmentSettings,
attachments,
calendarFeed,
@@ -87,6 +88,7 @@ import {
jiraImport,
labels,
personalLabels,
+ removeCommentReaction,
reviews,
search,
updateComment,
@@ -301,6 +303,8 @@ export const api = {
createComment,
updateComment,
deleteComment,
+ addCommentReaction,
+ removeCommentReaction,
// Time tracking API functions
time,
diff --git a/frontend/src/lib/api/misc.js b/frontend/src/lib/api/misc.js
index 58bc52046..c81298d47 100644
--- a/frontend/src/lib/api/misc.js
+++ b/frontend/src/lib/api/misc.js
@@ -83,6 +83,16 @@ export const deleteComment = (commentId) =>
fetchV2Data(`/comments/${commentId}`, {
method: 'DELETE',
});
+// Comment reactions (core#298): add returns the comment's grouped reactions.
+export const addCommentReaction = (commentId, reaction) =>
+ fetchV2Data(`/comments/${commentId}/reactions`, {
+ method: 'POST',
+ body: JSON.stringify({ reaction }),
+ });
+export const removeCommentReaction = (commentId, reaction) =>
+ fetchV2Data(`/comments/${commentId}/reactions/${encodeURIComponent(reaction)}`, {
+ method: 'DELETE',
+ });
// Attachments
export const attachments = {
diff --git a/frontend/src/lib/features/items/CommentReactions.svelte b/frontend/src/lib/features/items/CommentReactions.svelte
new file mode 100644
index 000000000..a169ef90f
--- /dev/null
+++ b/frontend/src/lib/features/items/CommentReactions.svelte
@@ -0,0 +1,158 @@
+
+
+{#if reactions.length > 0 || canReact}
+
+ {#each reactions as group (group.reaction)}
+
+
+
+ {/each}
+ {#if canReact}
+
+ {#if pickerOpen}
+
+ {#each REACTIONS as reaction (reaction.key)}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+{/if}
diff --git a/frontend/src/lib/features/items/Comments.svelte b/frontend/src/lib/features/items/Comments.svelte
index ab5332976..b9229c24e 100644
--- a/frontend/src/lib/features/items/Comments.svelte
+++ b/frontend/src/lib/features/items/Comments.svelte
@@ -21,6 +21,7 @@
import { isExpectedBackgroundSyncError } from '../../utils/backgroundSync.js';
import { workspacePermissions } from '../../stores/workspacePermissions.svelte.js';
import CannedResponsePicker from '../support/CannedResponsePicker.svelte';
+ import CommentReactions from './CommentReactions.svelte';
const COMMENT_PAGE_SIZE = 25;
@@ -51,6 +52,12 @@
// comment.edit_others holders may moderate foreign comments (mirrors
// requireCommentEdit in the v2 API).
const canEditOthersComments = $derived(workspacePermissions.canEditOthersComments(workspaceId));
+ // Reacting needs the comment permission (mirrors requireCommentPermission in the v2 API).
+ const canReact = $derived(Boolean(authStore.currentUser) && (!workspaceId || workspacePermissions.canComment(workspaceId)));
+
+ function setReactions(commentId, reactions) {
+ comments = comments.map((c) => (c.id === commentId ? { ...c, reactions } : c));
+ }
// Get user's timezone
let timezone = $derived(getUserTimezone(authStore.currentUser));
@@ -648,6 +655,14 @@
+ {#if comment.source !== 'approval'}
+ setReactions(comment.id, reactions)}
+ onError={(message) => (error = message)}
+ />
+ {/if}
{/if}
diff --git a/frontend/src/lib/locales/de/workspace.js b/frontend/src/lib/locales/de/workspace.js
index 7ed84c51c..272e2405d 100644
--- a/frontend/src/lib/locales/de/workspace.js
+++ b/frontend/src/lib/locales/de/workspace.js
@@ -367,6 +367,15 @@ export default {
internal: 'Intern',
oldestFirst: 'Älteste zuerst',
newestFirst: 'Neueste zuerst',
+ addReaction: 'Reaktion hinzufügen',
+ reactionFailed: 'Reaktion konnte nicht gespeichert werden',
+ reactedBy: '{reaction} von {names}',
+ reactionThumbsUp: 'Daumen hoch',
+ reactionCheck: 'Erledigt',
+ reactionEyes: 'Gesehen',
+ reactionHeart: 'Danke',
+ reactionLaugh: 'Lachen',
+ reactionHooray: 'Super',
internalNoteHintGeneral: 'Nur für Teammitglieder sichtbar',
},
todo: {
diff --git a/frontend/src/lib/locales/en/workspace.js b/frontend/src/lib/locales/en/workspace.js
index 4c539b36e..02b3ea6cf 100644
--- a/frontend/src/lib/locales/en/workspace.js
+++ b/frontend/src/lib/locales/en/workspace.js
@@ -589,6 +589,15 @@ export default {
newestFirst: 'Newest first',
agentAuthored: 'Authored by an AI agent',
agentOwnedBy: 'AI agent owned by {owner}',
+ addReaction: 'Add reaction',
+ reactionFailed: 'Failed to update reaction',
+ reactedBy: '{reaction} by {names}',
+ reactionThumbsUp: 'Thumbs up',
+ reactionCheck: 'Done',
+ reactionEyes: 'Seen',
+ reactionHeart: 'Thanks',
+ reactionLaugh: 'Laugh',
+ reactionHooray: 'Hooray',
},
todo: {
diff --git a/frontend/src/lib/locales/fr/workspace.js b/frontend/src/lib/locales/fr/workspace.js
index ac6636a15..ae4b0c489 100644
--- a/frontend/src/lib/locales/fr/workspace.js
+++ b/frontend/src/lib/locales/fr/workspace.js
@@ -461,6 +461,15 @@ export default {
newestFirst: 'Plus récents en premier',
agentAuthored: 'Rédigé par un agent IA',
agentOwnedBy: 'Agent IA détenu par {owner}',
+ addReaction: 'Ajouter une réaction',
+ reactionFailed: 'Impossible de mettre à jour la réaction',
+ reactedBy: '{reaction} par {names}',
+ reactionThumbsUp: 'Pouce levé',
+ reactionCheck: 'Terminé',
+ reactionEyes: 'Vu',
+ reactionHeart: 'Merci',
+ reactionLaugh: 'Rire',
+ reactionHooray: 'Bravo',
},
todo: {
diff --git a/frontend/src/lib/locales/ru/workspace.js b/frontend/src/lib/locales/ru/workspace.js
index 894b0c66a..921a12855 100644
--- a/frontend/src/lib/locales/ru/workspace.js
+++ b/frontend/src/lib/locales/ru/workspace.js
@@ -64,7 +64,7 @@ export default {
},
comments: {
- failedToLoad: 'Не удалось загрузить комментарии', failedToCreate: 'Не удалось отправить комментарий', confirmDelete: 'Удалить этот комментарий?', failedToDelete: 'Не удалось удалить комментарий', failedToUpdate: 'Не удалось обновить комментарий', edited: 'изменено', newCommentsAvailable: 'Новых: {count}', editComment: 'Изменить комментарий', deleteComment: 'Удалить комментарий', editPlaceholder: 'Измените комментарий…', writePlaceholder: 'Добавить комментарий…', markdownSupported: 'Поддерживается Markdown', posting: 'Отправка…', comment: 'Комментарий', noComments: 'Комментариев пока нет', beFirstToComment: 'Оставьте первый комментарий к этому элементу.', internalNote: 'Внутренняя заметка', internalNoteHint: 'Не видна на портале', internalNoteHintGeneral: 'Видна только участникам команды', internal: 'Внутренний', oldestFirst: 'Сначала старые', newestFirst: 'Сначала новые', agentAuthored: 'Автор — ИИ-агент', agentOwnedBy: 'Владелец ИИ-агента: {owner}',
+ failedToLoad: 'Не удалось загрузить комментарии', failedToCreate: 'Не удалось отправить комментарий', confirmDelete: 'Удалить этот комментарий?', failedToDelete: 'Не удалось удалить комментарий', failedToUpdate: 'Не удалось обновить комментарий', edited: 'изменено', newCommentsAvailable: 'Новых: {count}', editComment: 'Изменить комментарий', deleteComment: 'Удалить комментарий', editPlaceholder: 'Измените комментарий…', writePlaceholder: 'Добавить комментарий…', markdownSupported: 'Поддерживается Markdown', posting: 'Отправка…', comment: 'Комментарий', noComments: 'Комментариев пока нет', beFirstToComment: 'Оставьте первый комментарий к этому элементу.', internalNote: 'Внутренняя заметка', internalNoteHint: 'Не видна на портале', internalNoteHintGeneral: 'Видна только участникам команды', internal: 'Внутренний', oldestFirst: 'Сначала старые', newestFirst: 'Сначала новые', agentAuthored: 'Автор — ИИ-агент', agentOwnedBy: 'Владелец ИИ-агента: {owner}', addReaction: 'Добавить реакцию', reactionFailed: 'Не удалось обновить реакцию', reactedBy: '{reaction} от: {names}', reactionThumbsUp: 'Нравится', reactionCheck: 'Готово', reactionEyes: 'Просмотрено', reactionHeart: 'Спасибо', reactionLaugh: 'Смех', reactionHooray: 'Ура',
},
todo: {
failedToCreate: 'Не удалось создать задачу', confirmDelete: 'Удалить эту задачу?', deleteTask: 'Удалить задачу', failedToDelete: 'Не удалось удалить задачу', loadingTasks: 'Загрузка задач…', myPersonalTasks: 'Мои личные задачи', whatNeedsToBeDone: 'Что нужно сделать?', addPersonalTask: 'Добавить личную задачу', noPersonalTasks: 'Личных задач нет', addFirstTask: 'Добавьте первую задачу, чтобы не забыть о ней.', ofPersonalTasksRemaining: 'Осталось личных задач: {count} из {total}',
diff --git a/frontend/src/lib/locales/zh-CN/workspace.js b/frontend/src/lib/locales/zh-CN/workspace.js
index fab25c02b..79b33941a 100644
--- a/frontend/src/lib/locales/zh-CN/workspace.js
+++ b/frontend/src/lib/locales/zh-CN/workspace.js
@@ -366,6 +366,15 @@ export default {
internal: '内部',
oldestFirst: '最早优先',
newestFirst: '最新优先',
+ addReaction: '添加回应',
+ reactionFailed: '无法更新回应',
+ reactedBy: '{names} 回应了 {reaction}',
+ reactionThumbsUp: '赞',
+ reactionCheck: '已完成',
+ reactionEyes: '已查看',
+ reactionHeart: '感谢',
+ reactionLaugh: '大笑',
+ reactionHooray: '欢呼',
},
todo: {
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 5461a5a32..302e312b6 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -2870,6 +2870,38 @@ var Catalog = []Migration{
ALTER TABLE sla_goal_targets DROP COLUMN IF EXISTS priority_id;
`,
},
+ {
+ Version: "20261105_comment_reactions",
+ Name: "React to comments with a fixed set of emoji (core#298)",
+ CheckSQLite: sqliteTableCheck("comment_reactions"),
+ CheckPostgres: pgTableCheck("comment_reactions"),
+ SQLite: `
+ CREATE TABLE IF NOT EXISTS comment_reactions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+ );
+ CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+ `,
+ Postgres: `
+ CREATE TABLE IF NOT EXISTS comment_reactions (
+ id SERIAL PRIMARY KEY,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+ );
+ CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+ `,
+ },
}
// applyAssetReportsWorkspacePinned backfills form-mode asset reports that have
diff --git a/internal/database/schema/content.sql b/internal/database/schema/content.sql
index fabf1b44a..ba433fbf1 100644
--- a/internal/database/schema/content.sql
+++ b/internal/database/schema/content.sql
@@ -57,6 +57,20 @@ CREATE INDEX IF NOT EXISTS idx_comments_item_id ON comments(item_id);
CREATE INDEX IF NOT EXISTS idx_comments_author_id ON comments(author_id);
CREATE INDEX IF NOT EXISTS idx_comments_portal_customer_id ON comments(portal_customer_id);
+-- Comment reactions: one row per user, comment, and reaction key
+CREATE TABLE IF NOT EXISTS comment_reactions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+);
+
+CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+
-- Diagram System Tables
CREATE TABLE IF NOT EXISTS item_diagrams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/internal/database/schema/content_postgres.sql b/internal/database/schema/content_postgres.sql
index c87f23bcc..ffc3a8db3 100644
--- a/internal/database/schema/content_postgres.sql
+++ b/internal/database/schema/content_postgres.sql
@@ -57,6 +57,20 @@ CREATE INDEX IF NOT EXISTS idx_comments_item_id ON comments(item_id);
CREATE INDEX IF NOT EXISTS idx_comments_author_id ON comments(author_id);
CREATE INDEX IF NOT EXISTS idx_comments_portal_customer_id ON comments(portal_customer_id);
+-- Comment reactions: one row per user, comment, and reaction key
+CREATE TABLE IF NOT EXISTS comment_reactions (
+ id SERIAL PRIMARY KEY,
+ comment_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(comment_id, user_id, reaction)
+);
+
+CREATE INDEX IF NOT EXISTS idx_comment_reactions_user_id ON comment_reactions(user_id);
+
-- Diagram System Tables
CREATE TABLE IF NOT EXISTS item_diagrams (
id SERIAL PRIMARY KEY,
diff --git a/internal/models/item.go b/internal/models/item.go
index 809ded2c9..5956fd1b0 100644
--- a/internal/models/item.go
+++ b/internal/models/item.go
@@ -245,6 +245,18 @@ type Comment struct {
IsAgent bool `json:"is_agent,omitempty"`
// AgentOwnerName is permission-filtered by the comment-list handler.
AgentOwnerName string `json:"agent_owner_name,omitempty"`
+ // Reactions groups the emoji reactions on a human comment in the fixed
+ // order of services.CommentReactionKeys. Approval rows never carry any.
+ Reactions []CommentReaction `json:"reactions,omitempty" db:"-"`
+}
+
+// CommentReaction is one reaction key on a comment with the users who chose
+// it, oldest first.
+type CommentReaction struct {
+ Reaction string `json:"reaction"`
+ Count int `json:"count"`
+ UserIDs []int `json:"user_ids"`
+ UserNames []string `json:"user_names"`
}
// Mention represents an @mention in a comment or item description
diff --git a/internal/restapi/v2/comments.go b/internal/restapi/v2/comments.go
index 8185b0b1c..fb6deb7f0 100644
--- a/internal/restapi/v2/comments.go
+++ b/internal/restapi/v2/comments.go
@@ -20,6 +20,8 @@ func registerCommentRoutes(builder *routeBuilder, deps Deps) {
builder.JSON(http.MethodPatch, "/comments/{comment_id}", http.StatusOK, true, AuthAuthenticated, []string{"items:write"}, updateComment(deps))
builder.Command(http.MethodDelete, "/comments/{comment_id}", AuthAuthenticated, []string{"items:delete"}, deleteComment(deps))
builder.JSON(http.MethodPost, "/comments/batch", http.StatusOK, false, AuthAuthenticated, []string{"items:read"}, commentsBatch(deps))
+ builder.JSON(http.MethodPost, "/comments/{comment_id}/reactions", http.StatusOK, false, AuthAuthenticated, []string{"items:write"}, addCommentReaction(deps))
+ builder.Command(http.MethodDelete, "/comments/{comment_id}/reactions/{reaction}", AuthAuthenticated, []string{"items:write"}, removeCommentReaction(deps))
}
type commentFeedDTO struct {
@@ -113,6 +115,9 @@ func commentsBatch(deps Deps) jsonOperation[commentBatchRequest, []commentBatchE
if err != nil {
return nil, internalError(err)
}
+ if err := attachBatchReactions(deps, pages); err != nil {
+ return nil, internalError(err)
+ }
entries := make([]commentBatchEntry, 0, len(visible))
for _, itemID := range visible {
page := pages[itemID]
@@ -162,6 +167,10 @@ func normalizeCommentBatchItemIDs(input []int) ([]int, error) {
return ids, nil
}
+type commentReactionRequest struct {
+ Reaction string `json:"reaction"`
+}
+
type commentPatchRequest struct {
Content Optional[string] `json:"content"`
}
@@ -190,6 +199,9 @@ func listComments(deps Deps) readOperation[commentFeedDTO] {
if err := renderCommentHTML(page.Comments); err != nil {
return commentFeedDTO{}, internalError(err)
}
+ if err := deps.Comments.AttachReactions(page.Comments); err != nil {
+ return commentFeedDTO{}, internalError(err)
+ }
var total *int
if options.Before == nil && options.Since == nil {
count, err := deps.Comments.CountFeedByItemID(item.ID)
@@ -244,9 +256,12 @@ func getComment(deps Deps) readOperation[models.Comment] {
if err := requireCommentRead(r, deps, user.ID, comment.ItemID, comment.WorkspaceID); err != nil {
return models.Comment{}, err
}
- body := comment.Comment
- body.ContentHTML, err = markdown.Render(body.Content)
- return body, commentError(err)
+ body := []models.Comment{comment.Comment}
+ if err := deps.Comments.AttachReactions(body); err != nil {
+ return models.Comment{}, internalError(err)
+ }
+ body[0].ContentHTML, err = markdown.Render(body[0].Content)
+ return body[0], commentError(err)
}
}
@@ -270,11 +285,88 @@ func updateComment(deps Deps) jsonOperation[commentPatchRequest, models.Comment]
if err != nil {
return models.Comment{}, commentError(err)
}
- updated.ContentHTML, err = markdown.Render(updated.Content)
- return *updated, commentError(err)
+ body := []models.Comment{*updated}
+ if err := deps.Comments.AttachReactions(body); err != nil {
+ return models.Comment{}, internalError(err)
+ }
+ body[0].ContentHTML, err = markdown.Render(body[0].Content)
+ return body[0], commentError(err)
+ }
+}
+
+// addCommentReaction records the caller's reaction (core#298) and returns the
+// comment's grouped reactions. Reacting needs the same permission as
+// commenting; adding an existing reaction is a no-op.
+func addCommentReaction(deps Deps) jsonOperation[commentReactionRequest, []models.CommentReaction] {
+ return func(r *http.Request, input commentReactionRequest) ([]models.CommentReaction, error) {
+ user, comment, err := commentReactionTarget(r, deps, input.Reaction)
+ if err != nil {
+ return nil, err
+ }
+ reactions, err := deps.Comments.AddReaction(comment.ID, user.ID, input.Reaction)
+ return reactions, commentError(err)
+ }
+}
+
+// removeCommentReaction withdraws the caller's own reaction; removing a
+// reaction that does not exist is a no-op.
+func removeCommentReaction(deps Deps) commandOperation {
+ return func(r *http.Request) error {
+ reaction := r.PathValue("reaction")
+ user, comment, err := commentReactionTarget(r, deps, reaction)
+ if err != nil {
+ return err
+ }
+ return commentError(deps.Comments.RemoveReaction(comment.ID, user.ID, reaction))
}
}
+func commentReactionTarget(r *http.Request, deps Deps, reaction string) (*models.User, *services.CommentWithDetails, error) {
+ if !services.ValidCommentReaction(reaction) {
+ err := newError(http.StatusBadRequest, "invalid_request", "reaction must be one of "+strings.Join(services.CommentReactionKeys, ", "))
+ err.Details = map[string]string{"field": "reaction"}
+ return nil, nil, err
+ }
+ user, comment, err := commentTarget(r, deps)
+ if err != nil {
+ return nil, nil, err
+ }
+ if err := requireCommentRead(r, deps, user.ID, comment.ItemID, comment.WorkspaceID); err != nil {
+ return nil, nil, err
+ }
+ if err := requireCommentPermission(deps, user.ID, comment.WorkspaceID, models.PermissionItemComment); err != nil {
+ return nil, nil, err
+ }
+ return user, comment, nil
+}
+
+func attachBatchReactions(deps Deps, pages map[int]*services.CommentFeedPage) error {
+ var all []models.Comment
+ for _, page := range pages {
+ if page != nil {
+ all = append(all, page.Comments...)
+ }
+ }
+ if err := deps.Comments.AttachReactions(all); err != nil {
+ return err
+ }
+ byID := make(map[int][]models.CommentReaction, len(all))
+ for _, comment := range all {
+ if comment.Reactions != nil {
+ byID[comment.ID] = comment.Reactions
+ }
+ }
+ for _, page := range pages {
+ if page == nil {
+ continue
+ }
+ for i := range page.Comments {
+ page.Comments[i].Reactions = byID[page.Comments[i].ID]
+ }
+ }
+ return nil
+}
+
func deleteComment(deps Deps) commandOperation {
return func(r *http.Request) error {
user, comment, err := commentTarget(r, deps)
@@ -439,6 +531,8 @@ func commentError(err error) error {
return newError(http.StatusNotFound, "not_found", "Comment was not found")
case errors.As(err, &invalid):
return newError(http.StatusBadRequest, "invalid_request", invalid.Message)
+ case errors.Is(err, services.ErrInvalidCommentReaction):
+ return newError(http.StatusBadRequest, "invalid_request", err.Error())
case errors.As(err, &serviceErr):
return newError(serviceErr.StatusCode, "conflict", serviceErr.Message)
default:
diff --git a/internal/restapi/v2/contract-metadata.json b/internal/restapi/v2/contract-metadata.json
index 2e79a80ff..142adf424 100644
--- a/internal/restapi/v2/contract-metadata.json
+++ b/internal/restapi/v2/contract-metadata.json
@@ -3832,6 +3832,83 @@
]
}
},
+ "/comments/{comment_id}/reactions": {
+ "post": {
+ "description": "Add comment reaction. The server validates resource ownership and command preconditions before persisting changes. Unknown JSON fields are rejected when the operation accepts a body.",
+ "parameters": [
+ {
+ "Name": "comment_id",
+ "In": "path",
+ "Description": "The comment identifier.",
+ "Required": true,
+ "Schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "400": {},
+ "401": {},
+ "403": {},
+ "404": {},
+ "413": {},
+ "415": {},
+ "429": {},
+ "500": {}
+ },
+ "summary": "Add comment reaction",
+ "tags": [
+ "Work items"
+ ]
+ }
+ },
+ "/comments/{comment_id}/reactions/{reaction}": {
+ "delete": {
+ "description": "Remove comment reaction. The server verifies resource ownership and deletion preconditions before removing the resource.",
+ "parameters": [
+ {
+ "Name": "comment_id",
+ "In": "path",
+ "Description": "The comment identifier.",
+ "Required": true,
+ "Schema": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "Name": "reaction",
+ "In": "path",
+ "Description": "The reaction key: thumbs_up, check, eyes, heart, laugh, or hooray.",
+ "Required": true,
+ "Schema": {
+ "enum": [
+ "thumbs_up",
+ "check",
+ "eyes",
+ "heart",
+ "laugh",
+ "hooray"
+ ],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "400": {},
+ "401": {},
+ "403": {},
+ "404": {},
+ "429": {},
+ "500": {}
+ },
+ "summary": "Remove comment reaction",
+ "tags": [
+ "Work items"
+ ]
+ }
+ },
"/condition-sets": {
"get": {
"description": "List condition sets. Results include only resources visible to the authenticated caller; collections use their documented stable order and bounds.",
diff --git a/internal/restapi/v2/router.go b/internal/restapi/v2/router.go
index 9b20eb9d7..d3df68464 100644
--- a/internal/restapi/v2/router.go
+++ b/internal/restapi/v2/router.go
@@ -451,6 +451,9 @@ type commentApplication interface {
Create(services.CreateCommentParams) (*services.CreateCommentResult, error)
UpdateWithEffects(services.UpdateCommentParams) (*models.Comment, error)
DeleteWithEffects(services.DeleteCommentParams) error
+ AttachReactions([]models.Comment) error
+ AddReaction(commentID, userID int, reaction string) ([]models.CommentReaction, error)
+ RemoveReaction(commentID, userID int, reaction string) error
}
type commentAccess interface {
diff --git a/internal/services/comment_reactions.go b/internal/services/comment_reactions.go
new file mode 100644
index 000000000..27dcf4d8e
--- /dev/null
+++ b/internal/services/comment_reactions.go
@@ -0,0 +1,162 @@
+package services
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "slices"
+
+ "windshift/internal/models"
+ "windshift/internal/repository"
+)
+
+// CommentReactionKeys is the fixed set of reactions a user can leave on a
+// comment, in display order (core#298). Keys, not emoji, travel over the API
+// so clients render them consistently.
+var CommentReactionKeys = []string{"thumbs_up", "check", "eyes", "heart", "laugh", "hooray"}
+
+// ErrInvalidCommentReaction reports a reaction key outside CommentReactionKeys.
+var ErrInvalidCommentReaction = errors.New("reaction is not supported")
+
+// maxReactionCommentIDs bounds one IN clause; comment feeds return at most
+// MaxCommentFeedLimit rows per item, but batches span many items.
+const maxReactionCommentIDs = 500
+
+// ValidCommentReaction reports whether key is a supported reaction.
+func ValidCommentReaction(key string) bool {
+ return slices.Contains(CommentReactionKeys, key)
+}
+
+// AddReaction records userID's reaction on a comment. Adding the same
+// reaction twice is a no-op.
+func (s *CommentService) AddReaction(commentID, userID int, reaction string) ([]models.CommentReaction, error) {
+ if !ValidCommentReaction(reaction) {
+ return nil, ErrInvalidCommentReaction
+ }
+ itemID, err := s.reactionCommentItem(commentID)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.db.ExecWrite(`
+ INSERT INTO comment_reactions (comment_id, user_id, reaction) VALUES (?, ?, ?)
+ ON CONFLICT (comment_id, user_id, reaction) DO NOTHING`, commentID, userID, reaction); err != nil {
+ return nil, fmt.Errorf("add reaction to comment %d: %w", commentID, err)
+ }
+ PublishItemChange(itemID)
+ return s.ReactionsForComment(commentID)
+}
+
+// RemoveReaction withdraws userID's reaction from a comment. Removing a
+// reaction that does not exist is a no-op.
+func (s *CommentService) RemoveReaction(commentID, userID int, reaction string) error {
+ if !ValidCommentReaction(reaction) {
+ return ErrInvalidCommentReaction
+ }
+ itemID, err := s.reactionCommentItem(commentID)
+ if err != nil {
+ return err
+ }
+ if _, err := s.db.ExecWrite(`DELETE FROM comment_reactions WHERE comment_id = ? AND user_id = ? AND reaction = ?`,
+ commentID, userID, reaction); err != nil {
+ return fmt.Errorf("remove reaction from comment %d: %w", commentID, err)
+ }
+ PublishItemChange(itemID)
+ return nil
+}
+
+// ReactionsForComment returns the grouped reactions on one comment.
+func (s *CommentService) ReactionsForComment(commentID int) ([]models.CommentReaction, error) {
+ byComment, err := s.reactionsByComment([]int{commentID})
+ if err != nil {
+ return nil, err
+ }
+ reactions := byComment[commentID]
+ if reactions == nil {
+ reactions = []models.CommentReaction{}
+ }
+ return reactions, nil
+}
+
+// AttachReactions fills Reactions on the human comments in place. Approval
+// rows (non-positive IDs) are skipped.
+func (s *CommentService) AttachReactions(comments []models.Comment) error {
+ ids := make([]int, 0, len(comments))
+ for _, comment := range comments {
+ if comment.ID > 0 && comment.Source != "approval" {
+ ids = append(ids, comment.ID)
+ }
+ }
+ byComment, err := s.reactionsByComment(ids)
+ if err != nil {
+ return err
+ }
+ for i := range comments {
+ comments[i].Reactions = byComment[comments[i].ID]
+ }
+ return nil
+}
+
+func (s *CommentService) reactionCommentItem(commentID int) (int, error) {
+ var itemID int
+ err := s.db.QueryRow(`SELECT item_id FROM comments WHERE id = ?`, commentID).Scan(&itemID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, fmt.Errorf("comment %d: %w", commentID, repository.ErrNotFound)
+ }
+ if err != nil {
+ return 0, fmt.Errorf("find comment %d: %w", commentID, err)
+ }
+ return itemID, nil
+}
+
+func (s *CommentService) reactionsByComment(ids []int) (map[int][]models.CommentReaction, error) {
+ result := make(map[int][]models.CommentReaction)
+ for start := 0; start < len(ids); start += maxReactionCommentIDs {
+ chunk := ids[start:min(start+maxReactionCommentIDs, len(ids))]
+ args := make([]any, len(chunk))
+ for i, id := range chunk {
+ args[i] = id
+ }
+ rows, err := s.db.Query(fmt.Sprintf(`
+ SELECT r.comment_id, r.reaction, r.user_id,
+ COALESCE(NULLIF(TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), ''), u.username, 'Unknown User')
+ FROM comment_reactions r
+ JOIN users u ON u.id = r.user_id
+ WHERE r.comment_id IN (%s)
+ ORDER BY r.comment_id, r.created_at, r.id`, placeholders(len(chunk))), args...)
+ if err != nil {
+ return nil, fmt.Errorf("list comment reactions: %w", err)
+ }
+ for rows.Next() {
+ var commentID, userID int
+ var reaction, name string
+ if err := rows.Scan(&commentID, &reaction, &userID, &name); err != nil {
+ _ = rows.Close()
+ return nil, fmt.Errorf("scan comment reaction: %w", err)
+ }
+ result[commentID] = appendReaction(result[commentID], reaction, userID, name)
+ }
+ err = rows.Err()
+ _ = rows.Close()
+ if err != nil {
+ return nil, fmt.Errorf("read comment reactions: %w", err)
+ }
+ }
+ for id := range result {
+ slices.SortStableFunc(result[id], func(a, b models.CommentReaction) int {
+ return slices.Index(CommentReactionKeys, a.Reaction) - slices.Index(CommentReactionKeys, b.Reaction)
+ })
+ }
+ return result, nil
+}
+
+func appendReaction(groups []models.CommentReaction, reaction string, userID int, name string) []models.CommentReaction {
+ for i := range groups {
+ if groups[i].Reaction == reaction {
+ groups[i].Count++
+ groups[i].UserIDs = append(groups[i].UserIDs, userID)
+ groups[i].UserNames = append(groups[i].UserNames, name)
+ return groups
+ }
+ }
+ return append(groups, models.CommentReaction{Reaction: reaction, Count: 1, UserIDs: []int{userID}, UserNames: []string{name}})
+}
diff --git a/internal/services/comment_reactions_test.go b/internal/services/comment_reactions_test.go
new file mode 100644
index 000000000..049c53acb
--- /dev/null
+++ b/internal/services/comment_reactions_test.go
@@ -0,0 +1,118 @@
+package services
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+
+ "windshift/internal/database"
+ "windshift/internal/models"
+ "windshift/internal/repository"
+)
+
+func newCommentReactionTestService(t *testing.T) (*CommentService, database.Database) {
+ t.Helper()
+ db, err := database.NewSQLiteDBWithPoolSizes(filepath.Join(t.TempDir(), "windshift.db"), 1, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Initialize(); err != nil {
+ _ = db.Close()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ return NewCommentService(db), db
+}
+
+func insertReactionFixture(t *testing.T, db database.Database, query string, args ...any) int {
+ t.Helper()
+ var id int
+ if err := db.QueryRow(query+" RETURNING id", args...).Scan(&id); err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+// reactionFixture creates a workspace item with one comment by alice and
+// returns the comment ID with the user IDs of alice and bob.
+func reactionFixture(t *testing.T, db database.Database) (commentID, alice, bob int) {
+ t.Helper()
+ alice = insertReactionFixture(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('alice@example.com', 'alice', 'Alice', 'Doe')`)
+ bob = insertReactionFixture(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('bob@example.com', 'bob', '', '')`)
+ workspace := insertReactionFixture(t, db, `INSERT INTO workspaces (name, key) VALUES ('Reactions', 'REACT')`)
+ item := insertReactionFixture(t, db, `INSERT INTO items (workspace_id, workspace_item_number, title, frac_index) VALUES (?, 1, 'Item', 'a0')`, workspace)
+ commentID = insertReactionFixture(t, db, `INSERT INTO comments (item_id, author_id, content) VALUES (?, ?, 'Please review')`, item, alice)
+ return commentID, alice, bob
+}
+
+func TestCommentReactionsGroupInKeyOrderAndAreIdempotent(t *testing.T) {
+ service, db := newCommentReactionTestService(t)
+ commentID, alice, bob := reactionFixture(t, db)
+
+ for _, step := range []struct {
+ user int
+ reaction string
+ }{{bob, "eyes"}, {alice, "thumbs_up"}, {bob, "thumbs_up"}, {bob, "thumbs_up"}} {
+ if _, err := service.AddReaction(commentID, step.user, step.reaction); err != nil {
+ t.Fatalf("AddReaction(%d, %q) error = %v", step.user, step.reaction, err)
+ }
+ }
+
+ got, err := service.ReactionsForComment(commentID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || got[0].Reaction != "thumbs_up" || got[1].Reaction != "eyes" {
+ t.Fatalf("reactions = %+v, want thumbs_up then eyes", got)
+ }
+ if got[0].Count != 2 || got[0].UserIDs[0] != alice || got[0].UserNames[0] != "Alice Doe" || got[0].UserNames[1] != "bob" {
+ t.Fatalf("thumbs_up = %+v, want Alice Doe then bob once each", got[0])
+ }
+
+ if err := service.RemoveReaction(commentID, bob, "thumbs_up"); err != nil {
+ t.Fatal(err)
+ }
+ if err := service.RemoveReaction(commentID, bob, "thumbs_up"); err != nil {
+ t.Fatalf("removing a missing reaction should be a no-op, got %v", err)
+ }
+ comments := []models.Comment{{ID: commentID, Source: "human"}, {ID: -1, Source: "approval"}}
+ if err := service.AttachReactions(comments); err != nil {
+ t.Fatal(err)
+ }
+ if r := comments[0].Reactions; len(r) != 2 || r[0].Count != 1 || r[0].UserIDs[0] != alice {
+ t.Fatalf("attached reactions = %+v, want alice's thumbs_up and bob's eyes", r)
+ }
+ if comments[1].Reactions != nil {
+ t.Fatalf("approval row got reactions: %+v", comments[1].Reactions)
+ }
+}
+
+func TestCommentReactionsRejectUnknownKeysAndComments(t *testing.T) {
+ service, db := newCommentReactionTestService(t)
+ commentID, alice, _ := reactionFixture(t, db)
+
+ if _, err := service.AddReaction(commentID, alice, "rocket"); !errors.Is(err, ErrInvalidCommentReaction) {
+ t.Fatalf("AddReaction(rocket) error = %v, want ErrInvalidCommentReaction", err)
+ }
+ if _, err := service.AddReaction(commentID+100, alice, "check"); !errors.Is(err, repository.ErrNotFound) {
+ t.Fatalf("AddReaction(missing comment) error = %v, want ErrNotFound", err)
+ }
+}
+
+func TestCommentReactionsCascadeWithTheComment(t *testing.T) {
+ service, db := newCommentReactionTestService(t)
+ commentID, alice, _ := reactionFixture(t, db)
+ if _, err := service.AddReaction(commentID, alice, "check"); err != nil {
+ t.Fatal(err)
+ }
+ if err := service.Delete(commentID); err != nil {
+ t.Fatal(err)
+ }
+ var n int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM comment_reactions`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Fatalf("comment_reactions rows after comment delete = %d, want 0", n)
+ }
+}
From 32a392acc0a75ef2e805e51571eb443f925cffac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dominik=20B=C3=B6ckle?=
<43860910+dpb97@users.noreply.github.com>
Date: Wed, 21 Oct 2026 09:52:54 +0200
Subject: [PATCH 2/2] fix(deps): bump Go to 1.27.3 for crypto/x509 fix
(GO-2026-6809)
Co-Authored-By: Claude Opus 5.5
---
Dockerfile | 2 +-
Dockerfile.logbook | 2 +-
deploy/coding-agent/Dockerfile | 2 +-
deploy/windshift-runner/Dockerfile | 2 +-
go.mod | 2 +-
scripts/demo/Dockerfile | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 758f9d8f6..e58aed551 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,7 +23,7 @@ RUN VITE_APP_VERSION_CODE="${VERSION}" \
npm run build
# Stage 2: Build Go binary
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS builder
# Install build dependencies (no gcc/musl-dev needed - pure Go SQLite driver)
RUN apk add --no-cache git tzdata
diff --git a/Dockerfile.logbook b/Dockerfile.logbook
index fd6df0a13..80187b31f 100644
--- a/Dockerfile.logbook
+++ b/Dockerfile.logbook
@@ -1,7 +1,7 @@
# Multi-stage build for Windshift Logbook service
# Stage 1: Build Go binary
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS builder
# Install build dependencies
RUN apk add --no-cache git
diff --git a/deploy/coding-agent/Dockerfile b/deploy/coding-agent/Dockerfile
index f51051eef..2149410d7 100644
--- a/deploy/coding-agent/Dockerfile
+++ b/deploy/coding-agent/Dockerfile
@@ -21,7 +21,7 @@
# --------------------------------------------------------------------
# Stage 1: cross-compile the ws CLI for linux/
# --------------------------------------------------------------------
-FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS ws-build
+FROM golang:1.27.3-bookworm@sha256:32fa283222e2905d9fee971f60c842e5c815fdc036cf7ea599bf36bd7ca8f67f AS ws-build
WORKDIR /src
diff --git a/deploy/windshift-runner/Dockerfile b/deploy/windshift-runner/Dockerfile
index 624550aeb..89cc2381d 100644
--- a/deploy/windshift-runner/Dockerfile
+++ b/deploy/windshift-runner/Dockerfile
@@ -26,7 +26,7 @@
# --------------------------------------------------------------------
# Stage 1: build windshift-runner + windshift-triage
# --------------------------------------------------------------------
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS build
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
diff --git a/go.mod b/go.mod
index 860349672..e568fec51 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module windshift
-go 1.27.2
+go 1.27.3
require (
charm.land/bubbles/v2 v2.2.0
diff --git a/scripts/demo/Dockerfile b/scripts/demo/Dockerfile
index 2330116c3..8a9527c3c 100644
--- a/scripts/demo/Dockerfile
+++ b/scripts/demo/Dockerfile
@@ -26,7 +26,7 @@ RUN npm run build
# ============================================================
# Stage 1b: Build windshift server binary
# ============================================================
-FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
+FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS builder
# Install build dependencies
RUN apk add --no-cache git