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/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/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/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..c1b319446 --- /dev/null +++ b/frontend/src/lib/features/items/CommentReactions.svelte @@ -0,0 +1,159 @@ + + +{#if reactions.length > 0 || canReact} +
+ {#each reactions as group (group.reaction)} + + + + {/each} + {#if canReact} + + {#if pickerOpen} + + {/if} + {/if} +
+{/if} diff --git a/frontend/src/lib/features/items/Comments.svelte b/frontend/src/lib/features/items/Comments.svelte index 2dcd92074..63f28698f 100644 --- a/frontend/src/lib/features/items/Comments.svelte +++ b/frontend/src/lib/features/items/Comments.svelte @@ -22,6 +22,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; @@ -52,6 +53,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)); @@ -649,6 +656,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/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/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/release.sh b/release.sh index c21861436..46bb0b172 100755 --- a/release.sh +++ b/release.sh @@ -888,7 +888,7 @@ write_release_provenance() { echo "" echo "Resolved base image digests at build time" echo "-----------------------------------------" - for image in node:24.18.0-alpine golang:1.27.2-alpine golang:1.27.2-bookworm node:lts-slim; do + for image in node:24.18.0-alpine golang:1.27.3-alpine golang:1.27.3-bookworm node:lts-slim; do docker buildx imagetools inspect "$image" 2>/dev/null | grep -E 'Name:|Digest:' || true done fi 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