From 110e755adddcdc194b9cffc501bf29339e787131 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 22 Jun 2026 22:30:42 +0800 Subject: [PATCH] feat(kanban): card comments (DB board) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Comments section to the card peek for the DB-backed board: list, post, and delete (own comments; admins/owners may delete any). Comments are a DB-board feature — file boards keep discussion in the note body. - migration 0016_kanban_comments: kanban_card_comments (id, workspace_id, card_id, author_user_id, body, timestamps; FK cascades). - handlers/kanban/comment.rs: list / create / delete with role gates and author-or-admin delete; timestamps CAST AS CHAR (sqlx no-time-feature). - routes + module registration. - api.ts: listComments / createComment / deleteComment + KanbanComment type. - shared: BoardComment type + BoardSurfaceProps (loadComments / addComment / deleteComment / currentUser) threaded to BoardPeek, which fetches on open and renders the thread + a composer; delete shows only on the current user's own comments. Section hidden when no loader is supplied (file board). - Kanban.tsx wires the callbacks (+ getStoredUsername for the current user). - kanban_tests.rs: card_comments_crud. Verified: cargo check + 39 kanban_tests pass against MySQL (incl. the new test and the 0016 migration), web tsc clean, and a harness confirmed the peek thread (render, add, and delete-own-only — lee's comment had no delete for user jack). Note: migration 0016 (D2 webhooks should take 0017). Follow-up: realtime broadcast for comments, edit, and @mentions. Co-Authored-By: Claude Opus 4.8 --- services/jtype-web/frontend/src/api.ts | 16 ++ .../jtype-web/frontend/src/pages/Kanban.tsx | 5 + .../migrations/0016_kanban_comments.down.sql | 1 + .../migrations/0016_kanban_comments.up.sql | 14 ++ services/jtype-web/src/db/migrations.rs | 6 + .../jtype-web/src/handlers/kanban/comment.rs | 155 ++++++++++++++++++ services/jtype-web/src/handlers/kanban/mod.rs | 1 + services/jtype-web/src/lib.rs | 8 + services/jtype-web/tests/kanban_tests.rs | 47 ++++++ shared/components/board/BoardPeek.tsx | 121 +++++++++++++- shared/components/board/BoardSurface.tsx | 8 + shared/components/board/types.ts | 8 +- shared/i18n/locales/en/messages.mjs | 2 +- shared/i18n/locales/en/messages.po | 24 +++ shared/i18n/locales/ja/messages.mjs | 2 +- shared/i18n/locales/ja/messages.po | 24 +++ shared/i18n/locales/ko/messages.mjs | 2 +- shared/i18n/locales/ko/messages.po | 24 +++ shared/i18n/locales/zh/messages.mjs | 2 +- shared/i18n/locales/zh/messages.po | 24 +++ shared/lib/board.ts | 3 + 21 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 services/jtype-web/migrations/0016_kanban_comments.down.sql create mode 100644 services/jtype-web/migrations/0016_kanban_comments.up.sql create mode 100644 services/jtype-web/src/handlers/kanban/comment.rs diff --git a/services/jtype-web/frontend/src/api.ts b/services/jtype-web/frontend/src/api.ts index 3cd80e7..659e3ba 100644 --- a/services/jtype-web/frontend/src/api.ts +++ b/services/jtype-web/frontend/src/api.ts @@ -332,6 +332,12 @@ export const api = { request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}`, { method: 'DELETE' }), listTrash: (workspaceId: string, boardId: string) => request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/trash`), + listComments: (workspaceId: string, cardId: string) => + request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/comments`), + createComment: (workspaceId: string, cardId: string, body: string) => + request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/comments`, { method: 'POST', body: JSON.stringify({ body }) }), + deleteComment: (workspaceId: string, commentId: string) => + request(`/api/v1/workspaces/${workspaceId}/kanban/comments/${commentId}`, { method: 'DELETE' }), listLabels: (workspaceId: string, boardId: string) => request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/labels`), @@ -825,6 +831,16 @@ export interface KanbanBoardFull extends KanbanBoardSummary { labels: KanbanLabel[] } +export interface KanbanComment { + id: string + cardId: string + authorUserId: string + author?: string | null + body: string + createdAt: string + updatedAt: string +} + export interface KanbanTrashItem { id: string cardId: string diff --git a/services/jtype-web/frontend/src/pages/Kanban.tsx b/services/jtype-web/frontend/src/pages/Kanban.tsx index 225c182..f7a22eb 100644 --- a/services/jtype-web/frontend/src/pages/Kanban.tsx +++ b/services/jtype-web/frontend/src/pages/Kanban.tsx @@ -5,6 +5,7 @@ import { PlusIcon, EllipsisHorizontalIcon, TrashIcon, ArchiveBoxIcon, ArrowUturn import { api, setSessionId, + getStoredUsername, isKanbanConflict, type KanbanBoardSummary, type KanbanBoardFull, @@ -385,6 +386,10 @@ export function Kanban() { error={error} assigneeOptions={assigneeOptions} tagOptions={tagOptions} + currentUser={getStoredUsername() ?? undefined} + loadComments={workspaceId ? (cardId) => api.kanban.listComments(workspaceId, cardId) : undefined} + addComment={workspaceId ? (cardId, body) => api.kanban.createComment(workspaceId, cardId, body) : undefined} + deleteComment={workspaceId ? (commentId) => api.kanban.deleteComment(workspaceId, commentId) : undefined} /> )} diff --git a/services/jtype-web/migrations/0016_kanban_comments.down.sql b/services/jtype-web/migrations/0016_kanban_comments.down.sql new file mode 100644 index 0000000..d95ef00 --- /dev/null +++ b/services/jtype-web/migrations/0016_kanban_comments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS kanban_card_comments; diff --git a/services/jtype-web/migrations/0016_kanban_comments.up.sql b/services/jtype-web/migrations/0016_kanban_comments.up.sql new file mode 100644 index 0000000..dc90b86 --- /dev/null +++ b/services/jtype-web/migrations/0016_kanban_comments.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE kanban_card_comments ( + id CHAR(36) NOT NULL, + workspace_id CHAR(36) NOT NULL, + card_id CHAR(36) NOT NULL, + author_user_id CHAR(36) NOT NULL, + body MEDIUMTEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_comments_card (card_id, created_at), + CONSTRAINT kanban_comments_workspace_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + CONSTRAINT kanban_comments_card_fk FOREIGN KEY (card_id) REFERENCES kanban_cards(id) ON DELETE CASCADE, + CONSTRAINT kanban_comments_author_fk FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/services/jtype-web/src/db/migrations.rs b/services/jtype-web/src/db/migrations.rs index b3f7704..8f16d52 100644 --- a/services/jtype-web/src/db/migrations.rs +++ b/services/jtype-web/src/db/migrations.rs @@ -98,6 +98,12 @@ fn all_migrations() -> Vec { up: include_str!("../../migrations/0015_login_otp.up.sql"), down: include_str!("../../migrations/0015_login_otp.down.sql"), }, + Migration { + version: 16, + name: "kanban_comments", + up: include_str!("../../migrations/0016_kanban_comments.up.sql"), + down: include_str!("../../migrations/0016_kanban_comments.down.sql"), + }, ] } diff --git a/services/jtype-web/src/handlers/kanban/comment.rs b/services/jtype-web/src/handlers/kanban/comment.rs new file mode 100644 index 0000000..5d7c7c4 --- /dev/null +++ b/services/jtype-web/src/handlers/kanban/comment.rs @@ -0,0 +1,155 @@ +//! Card comment handlers (DB board). +//! +//! Endpoints: +//! GET /api/v1/workspaces/:workspace_id/kanban/cards/:card_id/comments +//! POST /api/v1/workspaces/:workspace_id/kanban/cards/:card_id/comments +//! DELETE /api/v1/workspaces/:workspace_id/kanban/comments/:comment_id +//! +//! Comments are a DB-board feature (file boards keep discussion in the note body). + +use axum::{ + extract::{Path, State}, + http::HeaderMap, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use uuid::Uuid; + +use super::clamp_str; +use crate::error::AppError; +use crate::handlers::workspace::require_workspace_role; +use crate::middleware::auth::extract_user; +use crate::AppState; + +const MAX_COMMENT: usize = 16_000; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KanbanComment { + pub id: String, + pub card_id: String, + pub author_user_id: String, + pub author: Option, + pub body: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCommentRequest { + pub body: String, +} + +const SELECT_COMMENT: &str = r#"SELECT c.id, c.card_id, c.author_user_id, u.username AS author, c.body, + CAST(c.created_at AS CHAR) AS created_at, CAST(c.updated_at AS CHAR) AS updated_at +FROM kanban_card_comments c +LEFT JOIN users u ON u.id = c.author_user_id"#; + +fn row_to_comment(r: &sqlx::mysql::MySqlRow) -> Result { + Ok(KanbanComment { + id: r.try_get("id")?, + card_id: r.try_get("card_id")?, + author_user_id: r.try_get("author_user_id")?, + author: r.try_get("author")?, + body: r.try_get("body")?, + created_at: r.try_get("created_at")?, + updated_at: r.try_get("updated_at")?, + }) +} + +async fn ensure_card_in_workspace( + pool: &sqlx::Pool, + workspace_id: &str, + card_id: &str, +) -> Result<(), AppError> { + let exists: Option = + sqlx::query_scalar("SELECT id FROM kanban_cards WHERE id = ? AND workspace_id = ?") + .bind(card_id) + .bind(workspace_id) + .fetch_optional(pool) + .await?; + if exists.is_none() { + return Err(AppError::NotFound); + } + Ok(()) +} + +pub async fn list_comments( + State(state): State, + headers: HeaderMap, + Path((workspace_id, card_id)): Path<(String, String)>, +) -> Result { + let user = extract_user(&state.pool, &headers).await?; + require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor", "viewer"]).await?; + ensure_card_in_workspace(&state.pool, &workspace_id, &card_id).await?; + + let rows = sqlx::query(&format!("{SELECT_COMMENT} WHERE c.card_id = ? ORDER BY c.created_at ASC")) + .bind(&card_id) + .fetch_all(&state.pool) + .await?; + let out = rows.iter().map(row_to_comment).collect::, _>>()?; + Ok(Json(out).into_response()) +} + +pub async fn create_comment( + State(state): State, + headers: HeaderMap, + Path((workspace_id, card_id)): Path<(String, String)>, + Json(payload): Json, +) -> Result { + let user = extract_user(&state.pool, &headers).await?; + require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor"]).await?; + ensure_card_in_workspace(&state.pool, &workspace_id, &card_id).await?; + + let body = clamp_str(payload.body.trim(), MAX_COMMENT); + if body.is_empty() { + return Err(AppError::BadRequest("comment cannot be empty".into())); + } + + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO kanban_card_comments (id, workspace_id, card_id, author_user_id, body) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&workspace_id) + .bind(&card_id) + .bind(&user.id) + .bind(&body) + .execute(&state.pool) + .await?; + + let row = sqlx::query(&format!("{SELECT_COMMENT} WHERE c.id = ?")) + .bind(&id) + .fetch_one(&state.pool) + .await?; + Ok(Json(row_to_comment(&row)?).into_response()) +} + +pub async fn delete_comment( + State(state): State, + headers: HeaderMap, + Path((workspace_id, comment_id)): Path<(String, String)>, +) -> Result { + let user = extract_user(&state.pool, &headers).await?; + require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor"]).await?; + + let row = sqlx::query("SELECT author_user_id FROM kanban_card_comments WHERE id = ? AND workspace_id = ?") + .bind(&comment_id) + .bind(&workspace_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + let author: String = row.try_get("author_user_id")?; + // Authors delete their own; admins/owners may delete any. + if author != user.id { + require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin"]).await?; + } + + sqlx::query("DELETE FROM kanban_card_comments WHERE id = ?") + .bind(&comment_id) + .execute(&state.pool) + .await?; + Ok(axum::http::StatusCode::NO_CONTENT.into_response()) +} diff --git a/services/jtype-web/src/handlers/kanban/mod.rs b/services/jtype-web/src/handlers/kanban/mod.rs index 11f7c25..d777eda 100644 --- a/services/jtype-web/src/handlers/kanban/mod.rs +++ b/services/jtype-web/src/handlers/kanban/mod.rs @@ -11,6 +11,7 @@ pub mod board; pub mod card; pub mod column; +pub mod comment; pub mod label; use sqlx::Row; diff --git a/services/jtype-web/src/lib.rs b/services/jtype-web/src/lib.rs index ca78c79..34a2bbb 100644 --- a/services/jtype-web/src/lib.rs +++ b/services/jtype-web/src/lib.rs @@ -370,6 +370,14 @@ pub fn build_app( "/api/v1/workspaces/:workspace_id/kanban/cards/:card_id/restore", post(handlers::kanban::card::restore_card), ) + .route( + "/api/v1/workspaces/:workspace_id/kanban/cards/:card_id/comments", + get(handlers::kanban::comment::list_comments).post(handlers::kanban::comment::create_comment), + ) + .route( + "/api/v1/workspaces/:workspace_id/kanban/comments/:comment_id", + axum::routing::delete(handlers::kanban::comment::delete_comment), + ) // Labels .route( "/api/v1/workspaces/:workspace_id/kanban/boards/:board_id/labels", diff --git a/services/jtype-web/tests/kanban_tests.rs b/services/jtype-web/tests/kanban_tests.rs index 4d87c64..391470b 100644 --- a/services/jtype-web/tests/kanban_tests.rs +++ b/services/jtype-web/tests/kanban_tests.rs @@ -1582,3 +1582,50 @@ async fn conflict_response_includes_latest_card_snapshot() { assert!(body["latest"]["updatedClock"].as_i64().unwrap() > base_clock); assert_eq!(body["baseUpdatedClock"], base_clock); } + +#[tokio::test] +async fn card_comments_crud() { + let (app, _pool) = common::setup().await; + let (token, _) = common::register_user(app.clone(), &common::uid()).await; + let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; + let (_s, board) = common::req( + app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), + Some(&token), Some(json!({ "name": "B" })), + ).await; + let board_id = board["id"].as_str().unwrap().to_string(); + let col = board["columns"][0]["id"].as_str().unwrap().to_string(); + let (_s, card) = common::req( + app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), + Some(&token), Some(json!({ "columnId": col, "title": "X" })), + ).await; + let card_id = card["id"].as_str().unwrap().to_string(); + + // create + let (status, c) = common::req( + app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/comments"), + Some(&token), Some(json!({ "body": "Looks good" })), + ).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(c["body"], "Looks good"); + assert!(c["author"].is_string()); + let comment_id = c["id"].as_str().unwrap().to_string(); + + // list → 1 + let (_s, list) = common::req( + app.clone(), "GET", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/comments"), + Some(&token), None, + ).await; + assert_eq!(list.as_array().unwrap().len(), 1); + + // delete (author) → 204, then list → 0 + let (status, _) = common::req( + app.clone(), "DELETE", &format!("/api/v1/workspaces/{ws_id}/kanban/comments/{comment_id}"), + Some(&token), None, + ).await; + assert_eq!(status, StatusCode::NO_CONTENT); + let (_s, list2) = common::req( + app, "GET", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/comments"), + Some(&token), None, + ).await; + assert_eq!(list2.as_array().unwrap().len(), 0); +} diff --git a/shared/components/board/BoardPeek.tsx b/shared/components/board/BoardPeek.tsx index 83bd58e..372b5d6 100644 --- a/shared/components/board/BoardPeek.tsx +++ b/shared/components/board/BoardPeek.tsx @@ -1,12 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon } from "@heroicons/react/24/outline"; +import { XMarkIcon, TrashIcon, ArrowsPointingOutIcon, EyeIcon, PencilSquareIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline"; import { renderToContainer } from "../../lib/markdown"; import { PRIORITIES, type BoardViewCard } from "../../lib/board"; import { fieldCls, EmojiField, ListboxSelect, TagMultiSelect } from "./controls"; import type { BoardOption } from "./types"; -import type { BoardTag } from "../../lib/board"; +import type { BoardTag, BoardComment } from "../../lib/board"; /** * Side peek for editing a card without leaving the board. Platform-agnostic: it @@ -19,6 +19,10 @@ export function BoardPeek({ assigneeOptions, tagOptions, loadNotes, + loadComments, + addComment, + deleteComment, + currentUser, onChange, onClose, onDelete, @@ -29,6 +33,10 @@ export function BoardPeek({ assigneeOptions?: BoardOption[]; tagOptions?: BoardTag[]; loadNotes?: (id: string) => Promise; + loadComments?: (id: string) => Promise; + addComment?: (id: string, body: string) => Promise; + deleteComment?: (commentId: string) => Promise; + currentUser?: string; onChange: (patch: Partial) => void; onClose: () => void; onDelete: () => void; @@ -37,6 +45,8 @@ export function BoardPeek({ const [draft, setDraft] = useState(card); const [notes, setNotes] = useState(card.notes ?? ""); const [mode, setMode] = useState<"write" | "preview">("write"); + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(""); const previewRef = useRef(null); const saveTimer = useRef | null>(null); @@ -56,6 +66,46 @@ export function BoardPeek({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [card.id]); + // Load comments when a card opens (DB board only). + useEffect(() => { + if (!loadComments) { + setComments([]); + return; + } + let cancelled = false; + setNewComment(""); + void loadComments(card.id) + .then((cs) => { + if (!cancelled) setComments(cs); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [card.id]); + + const submitComment = async () => { + const body = newComment.trim(); + if (!body || !addComment) return; + try { + const created = await addComment(card.id, body); + setComments((cs) => [...cs, created]); + setNewComment(""); + } catch { + /* surfaced by the caller's error state */ + } + }; + const removeComment = async (id: string) => { + if (!deleteComment) return; + try { + await deleteComment(id); + setComments((cs) => cs.filter((c) => c.id !== id)); + } catch { + /* ignore */ + } + }; + const debouncedPatch = useCallback( (patch: Partial) => { if (saveTimer.current) clearTimeout(saveTimer.current); @@ -239,6 +289,73 @@ export function BoardPeek({ ) : (
)} + + {loadComments && ( +
+ + + Comments + +
    + {comments.map((c) => ( +
  • +
    + {c.author ?? Someone} + {c.createdAt.slice(0, 16)} + {deleteComment && currentUser && c.author === currentUser && ( + + )} +
    +
    {c.body}
    +
  • + ))} + {comments.length === 0 && ( +
  • + No comments yet +
  • + )} +
+ {addComment && ( +
{ + e.preventDefault(); + void submitComment(); + }} + > +