Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions services/jtype-web/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ export const api = {
request<void>(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}`, { method: 'DELETE' }),
listTrash: (workspaceId: string, boardId: string) =>
request<KanbanTrashItem[]>(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/trash`),
listComments: (workspaceId: string, cardId: string) =>
request<KanbanComment[]>(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/comments`),
createComment: (workspaceId: string, cardId: string, body: string) =>
request<KanbanComment>(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/comments`, { method: 'POST', body: JSON.stringify({ body }) }),
deleteComment: (workspaceId: string, commentId: string) =>
request<void>(`/api/v1/workspaces/${workspaceId}/kanban/comments/${commentId}`, { method: 'DELETE' }),
getCardActivity: (workspaceId: string, cardId: string) =>
request<KanbanActivityEvent[]>(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/activity`),

Expand Down Expand Up @@ -829,6 +835,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 KanbanActivityEvent {
kind: string
at: string
Expand Down
5 changes: 5 additions & 0 deletions services/jtype-web/frontend/src/pages/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PlusIcon, EllipsisHorizontalIcon, TrashIcon, ArchiveBoxIcon, ArrowUturn
import {
api,
setSessionId,
getStoredUsername,
isKanbanConflict,
type KanbanBoardSummary,
type KanbanBoardFull,
Expand Down Expand Up @@ -397,6 +398,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}
loadActivity={workspaceId ? (cardId) => api.kanban.getCardActivity(workspaceId, cardId) : undefined}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS kanban_card_comments;
14 changes: 14 additions & 0 deletions services/jtype-web/migrations/0016_kanban_comments.up.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions services/jtype-web/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ fn all_migrations() -> Vec<Migration> {
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"),
},
]
}

Expand Down
155 changes: 155 additions & 0 deletions services/jtype-web/src/handlers/kanban/comment.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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<KanbanComment, AppError> {
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<sqlx::MySql>,
workspace_id: &str,
card_id: &str,
) -> Result<(), AppError> {
let exists: Option<String> =
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<AppState>,
headers: HeaderMap,
Path((workspace_id, card_id)): Path<(String, String)>,
) -> Result<Response, AppError> {
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::<Result<Vec<_>, _>>()?;
Ok(Json(out).into_response())
}

pub async fn create_comment(
State(state): State<AppState>,
headers: HeaderMap,
Path((workspace_id, card_id)): Path<(String, String)>,
Json(payload): Json<CreateCommentRequest>,
) -> Result<Response, AppError> {
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<AppState>,
headers: HeaderMap,
Path((workspace_id, comment_id)): Path<(String, String)>,
) -> Result<Response, AppError> {
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())
}
1 change: 1 addition & 0 deletions services/jtype-web/src/handlers/kanban/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
pub mod board;
pub mod card;
pub mod column;
pub mod comment;
pub mod label;

use sqlx::Row;
Expand Down
8 changes: 8 additions & 0 deletions services/jtype-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
.route(
"/api/v1/workspaces/:workspace_id/kanban/cards/:card_id/activity",
get(handlers::kanban::card::card_activity),
Expand Down
47 changes: 47 additions & 0 deletions services/jtype-web/tests/kanban_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1657,6 +1657,53 @@ async fn conflict_response_includes_latest_card_snapshot() {
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);
}

#[tokio::test]
async fn card_activity_reports_created_and_archived() {
let (app, _pool) = common::setup().await;
Expand Down
Loading
Loading