Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fbc6a37
feat: add task comments
Feb 12, 2026
ff2c7e9
fix: tighten comment insert rls + validate content
Feb 12, 2026
c708029
fix: show comment submission errors
Feb 12, 2026
0751fad
fix: clear stale comments on fetch failures
Feb 12, 2026
d89ae00
fix: prevent stale comment fetch race
Feb 12, 2026
a2e2802
chore: trigger kimi co-review hook
Feb 12, 2026
53e480d
chore: verify kimi hook dedupe
Feb 12, 2026
9f6f624
fix: harden comments API and UX limits
Feb 12, 2026
8a56608
chore: test auto kimi trigger logging
Feb 12, 2026
009135e
chore: test pre-push kimi auto trigger
Feb 12, 2026
8b98446
chore: verify pre-push kimi trigger v2
Feb 12, 2026
fe7ed98
chore: verify auto kimi trigger v3
Feb 12, 2026
987d4ac
ci: add Kimi co-review GitHub Action for PR pushes
Feb 12, 2026
24896bb
chore: validate Kimi GitHub Action
Feb 12, 2026
773f49b
fix: repair Kimi action workflow syntax
Feb 12, 2026
af1401b
ci: switch to reusable Kimi review workflow
Feb 12, 2026
b37b504
chore: re-run Kimi reusable action
Feb 12, 2026
aa5f4ce
feat: add comment update/delete endpoints
Feb 13, 2026
b93c217
chore: add comment updated_at and user index
Feb 13, 2026
8f20564
feat: handle 429 and add comment service actions
Feb 13, 2026
88ab594
feat: add edit/delete actions for task comments UI
Feb 13, 2026
49bb79d
fix: add UPDATE RLS policy for task comments
Feb 13, 2026
1014998
feat: add visual cooldown UX for comment 429
Feb 13, 2026
4ff8119
docs: update key endpoints for task comments API
Feb 13, 2026
e3bba46
fix: harden comment sanitization and delete error handling
Feb 13, 2026
572edfe
fix: keep comments on fetch error and use structured cooldown
Feb 13, 2026
b28a8a4
docs: sync guides for comments API, cooldown, and RLS
Feb 13, 2026
3ddaff6
fix: prevent duplicate edit requests in task comments
Feb 13, 2026
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
21 changes: 21 additions & 0 deletions .github/workflows/kimi-co-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Kimi PR Co-Review

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

permissions:
contents: read
pull-requests: write

concurrency:
group: kimi-co-review-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
kimi:
uses: kelvinCB/github-automation/.github/workflows/kimi-review-reusable.yml@main
with:
marker_prefix: kimi-action-review
secrets:
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
224 changes: 223 additions & 1 deletion backend/src/controllers/taskController.js
Original file line number Diff line number Diff line change
Expand Up @@ -377,10 +377,232 @@ const deleteTask = async (req, res) => {
}
};

/**
* Get all comments for a specific task
*/
const getComments = async (req, res) => {
try {
const { id: task_id } = req.params;
const user_id = req.user.id;
const rawLimit = Number.parseInt(String(req.query.limit ?? '50'), 10);
const rawOffset = Number.parseInt(String(req.query.offset ?? '0'), 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 200) : 50;
const offset = Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : 0;

if (!Number.isSafeInteger(offset + limit - 1)) {
return res.status(400).json({ error: 'Invalid pagination range' });
}

const db = req.supabase || supabaseClient.supabase;

// Verify task belongs to user
const { data: task, error: taskErr } = await db
.from('tasks')
.select('id')
.eq('id', task_id)
.eq('user_id', user_id)
.single();

if (taskErr || !task) {
return res.status(404).json({ error: 'Task not found' });
}

const { data: comments, error } = await db
.from('task_comments')
.select('id, task_id, user_id, author_name, author_avatar, content, created_at, updated_at')
.eq('task_id', task_id)
.order('created_at', { ascending: true })
.range(offset, offset + limit - 1);

if (error) throw error;

res.status(200).json({ comments });
} catch (error) {
console.error('Get comments error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};

const MAX_COMMENT_LENGTH = 2000;
const COMMENT_COOLDOWN_MS = 1500;

const sanitizeCommentContent = (value = '') => {
const escaped = String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#96;')
.replace(/\u0000/g, '');

return escaped.replace(/\s+/g, ' ').trim();
};

/**
* Add a new comment to a task
*/
const addComment = async (req, res) => {
try {
const { id: task_id } = req.params;
const { content } = req.body;
const user_id = req.user.id;

if (typeof content !== 'string') {
return res.status(400).json({ error: 'Content must be a string' });
}

const sanitized = sanitizeCommentContent(content);
if (!sanitized) {
return res.status(400).json({ error: 'Content is required' });
}

if (sanitized.length > MAX_COMMENT_LENGTH) {
return res.status(400).json({ error: `Content exceeds max length (${MAX_COMMENT_LENGTH})` });
}

const db = req.supabase || supabaseClient.supabase;

// Verify task belongs to user
const { data: task, error: taskErr } = await db
.from('tasks')
.select('id')
.eq('id', task_id)
.eq('user_id', user_id)
.single();

if (taskErr || !task) {
return res.status(404).json({ error: 'Task not found' });
}

// DB-backed cooldown (multi-instance safe)
const cooldownSince = new Date(Date.now() - COMMENT_COOLDOWN_MS).toISOString();
const { data: latestOwnComment } = await db
.from('task_comments')
.select('created_at')
.eq('user_id', user_id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();

if (latestOwnComment?.created_at && latestOwnComment.created_at > cooldownSince) {
const elapsedMs = Date.now() - new Date(latestOwnComment.created_at).getTime();
const retryAfterSeconds = Math.max(1, Math.ceil((COMMENT_COOLDOWN_MS - elapsedMs) / 1000));
res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({
error: `Please wait ${retryAfterSeconds}s before posting another comment.`,
retry_after_seconds: retryAfterSeconds,
});
}

// Get user profile for author info
const { data: profile } = await db
.from('profiles')
.select('username, display_name, avatar_url')
.eq('id', user_id)
.maybeSingle();

const author_name = profile?.display_name || profile?.username || 'User';
const author_avatar = profile?.avatar_url || null;

const { data: comment, error } = await db
.from('task_comments')
.insert([{
task_id,
user_id,
author_name,
author_avatar,
content: sanitized
}])
.select('id, task_id, user_id, author_name, author_avatar, content, created_at, updated_at')
.single();

if (error) throw error;

res.status(201).json({ comment });
} catch (error) {
console.error('Add comment error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};

const updateComment = async (req, res) => {
try {
const { id: task_id, commentId } = req.params;
const { content } = req.body;
const user_id = req.user.id;

if (typeof content !== 'string') {
return res.status(400).json({ error: 'Content must be a string' });
}

const sanitized = sanitizeCommentContent(content);
if (!sanitized) return res.status(400).json({ error: 'Content is required' });
if (sanitized.length > MAX_COMMENT_LENGTH) {
return res.status(400).json({ error: `Content exceeds max length (${MAX_COMMENT_LENGTH})` });
}

const db = req.supabase || supabaseClient.supabase;

const { data: updated, error } = await db
.from('task_comments')
.update({ content: sanitized, updated_at: new Date().toISOString() })
.eq('id', commentId)
.eq('task_id', task_id)
.eq('user_id', user_id)
.select('id, task_id, user_id, author_name, author_avatar, content, created_at, updated_at')
.single();

if (error || !updated) {
return res.status(404).json({ error: 'Comment not found or not allowed' });
}

res.status(200).json({ comment: updated });
} catch (error) {
console.error('Update comment error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};

const deleteComment = async (req, res) => {
try {
const { id: task_id, commentId } = req.params;
const user_id = req.user.id;
const db = req.supabase || supabaseClient.supabase;

const { data: deleted, error } = await db
.from('task_comments')
.delete()
.eq('id', commentId)
.eq('task_id', task_id)
.eq('user_id', user_id)
.select('id')
.maybeSingle();

if (error) {
console.error('Delete comment DB error:', error);
return res.status(500).json({ error: 'Failed to delete comment' });
}

if (!deleted) {
return res.status(404).json({ error: 'Comment not found or not allowed' });
}

res.status(200).json({ message: 'Comment deleted' });
} catch (error) {
console.error('Delete comment error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};

module.exports = {
createTask,
getTasks,
getTaskById,
updateTask,
deleteTask
deleteTask,
getComments,
addComment,
updateComment,
deleteComment
};
61 changes: 60 additions & 1 deletion backend/src/routes/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ const {
getTasks,
getTaskById,
updateTask,
deleteTask
deleteTask,
getComments,
addComment,
updateComment,
deleteComment
} = require('../controllers/taskController');

// Apply authentication middleware to all task routes
Expand Down Expand Up @@ -151,4 +155,59 @@ router.put('/:id', updateTask);
*/
router.delete('/:id', deleteTask);

/**
* @swagger
* /tasks/{id}/comments:
* get:
* summary: Get all comments for a task
* tags: [Tasks]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Task ID
* responses:
* 200:
* description: List of comments
*/
router.get('/:id/comments', getComments);

/**
* @swagger
* /tasks/{id}/comments:
* post:
* summary: Add a comment to a task
* tags: [Tasks]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Task ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - content
* properties:
* content:
* type: string
* responses:
* 201:
* description: Comment created
*/
router.post('/:id/comments', addComment);
router.patch('/:id/comments/:commentId', updateComment);
router.delete('/:id/comments/:commentId', deleteComment);

module.exports = router;
9 changes: 9 additions & 0 deletions docs/BACKEND_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ backend/
| GET | `/api/tasks/:id` | Get specific task by ID |
| PUT | `/api/tasks/:id` | Update an existing task |
| DELETE | `/api/tasks/:id` | Delete a task |
| GET | `/api/tasks/:id/comments` | Get comments for a task (supports `limit` + `offset`) |
| POST | `/api/tasks/:id/comments` | Create comment (cooldown/429 supported) |
| PATCH | `/api/tasks/:id/comments/:commentId` | Edit own comment |
| DELETE | `/api/tasks/:id/comments/:commentId` | Delete own comment |
| **Time Entries** | | |
| POST | `/api/time-entries/start` | Start task timer |
| POST | `/api/time-entries/stop` | Stop task timer |
Expand Down Expand Up @@ -227,6 +231,11 @@ Manages task CRUD operations with:
- `getTaskById`: Get single task (validates ownership)
- `updateTask`: Update task fields (validates ownership)
- `deleteTask`: Remove task (validates ownership)
- **Comment Operations**:
- `getComments`: Paginated task comments (`limit`/`offset`) with safe-range guard
- `addComment`: Sanitized create + backend cooldown (returns 429 + `Retry-After` + `retry_after_seconds`)
- `updateComment`: Edit own comment
- `deleteComment`: Delete own comment with explicit `404` (not found) vs `500` (DB error)
- **Security**: Prevents cross-user data access
- **Validation**: Status enum, UUID format, circular reference prevention

Expand Down
Loading
Loading