diff --git a/.github/workflows/kimi-co-review.yml b/.github/workflows/kimi-co-review.yml new file mode 100644 index 0000000..5fc4c54 --- /dev/null +++ b/.github/workflows/kimi-co-review.yml @@ -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 }} diff --git a/backend/src/controllers/taskController.js b/backend/src/controllers/taskController.js index 1426298..8d9d13c 100644 --- a/backend/src/controllers/taskController.js +++ b/backend/src/controllers/taskController.js @@ -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, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/`/g, '`') + .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 }; diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index d79954a..abcd751 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -6,7 +6,11 @@ const { getTasks, getTaskById, updateTask, - deleteTask + deleteTask, + getComments, + addComment, + updateComment, + deleteComment } = require('../controllers/taskController'); // Apply authentication middleware to all task routes @@ -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; diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index a947cda..850d579 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -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 | @@ -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 diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index f4d2575..fc411b8 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -62,6 +62,20 @@ Stores the tasks for each user. Each task is linked to a user and supports hiera | `created_at` | `timestamptz` | Not Null, Default `now()` | | `updated_at` | `timestamptz` | Not Null, Default `now()` | +### `public.task_comments` + +Stores comments on tasks. Each comment belongs to a task and is authored by a user. + +| Column | Type | Constraints | +|-----------------|---------------|------------| +| `id` | `uuid` | Primary Key, Default `gen_random_uuid()` | +| `task_id` | `bigint` | Not Null, Foreign Key to `public.tasks(id)`, On Delete CASCADE | +| `user_id` | `uuid` | Not Null, Foreign Key to `auth.users(id)` | +| `author_name` | `text` | Not Null | +| `author_avatar` | `text` | Nullable | +| `content` | `text` | Not Null | +| `created_at` | `timestamptz` | Not Null, Default `now()` | + ### `public.feature_requests` Stores user feedback, bug reports, and feature requests. @@ -149,6 +163,52 @@ CREATE POLICY "Users can delete own tasks" **Note:** Backend API implements additional user isolation at the application level through JWT validation. +### `task_comments` Table Policies + +Users can view and create comments **only on their own tasks**. Users can update/delete their own comments. + +```sql +-- Enable RLS +ALTER TABLE public.task_comments ENABLE ROW LEVEL SECURITY; + +-- Select: user can see comments where the parent task belongs to them +CREATE POLICY "Users can view comments on their own tasks" + ON public.task_comments + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.tasks + WHERE public.tasks.id = public.task_comments.task_id + AND public.tasks.user_id = auth.uid() + ) + ); + +-- Insert: user can add comments where the parent task belongs to them +CREATE POLICY "Users can create comments on their own tasks" + ON public.task_comments + FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.tasks + WHERE public.tasks.id = public.task_comments.task_id + AND public.tasks.user_id = auth.uid() + ) + ); + +-- Update: user can update their own comments +CREATE POLICY "Users can update their own comments" + ON public.task_comments + FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- Delete: user can delete their own comments +CREATE POLICY "Users can delete their own comments" + ON public.task_comments + FOR DELETE + USING (auth.uid() = user_id); +``` + ### `time_entries` Table Policies Users can only see and manage their own time entries. Policies assume `time_entries.user_id` is set on insert/update by the backend. diff --git a/docs/FRONTEND_GUIDE.md b/docs/FRONTEND_GUIDE.md index 6d83fad..1939de4 100644 --- a/docs/FRONTEND_GUIDE.md +++ b/docs/FRONTEND_GUIDE.md @@ -66,6 +66,7 @@ src/ │ ├── TaskForm.tsx # Task creation/editing form │ ├── TaskItem.tsx # Individual task component │ ├── TaskTimer.tsx # Time tracking component +│ ├── TaskComments.tsx # Task comments (create/edit/delete + cooldown UX) │ ├── TaskStats.tsx # Statistics component │ ├── TimeStatsView.tsx # Time analytics view │ ├── ProgressIcon.tsx # Custom progress icon @@ -955,6 +956,12 @@ const useApiCall = (apiFunction: () => Promise) => { }; ``` +### Task Comments Error/Cooldown Handling + +- `TaskComments` keeps previously loaded comments when a fetch error occurs (avoids flicker/context loss on transient errors). +- Cooldown UX is driven by structured backend metadata (`retry_after_seconds` / `Retry-After`) instead of parsing localized text. +- While cooldown is active, the submit button is disabled and a countdown message is shown. + ### User-Friendly Error Messages #### Error Display Components diff --git a/docs/PRD_GUIDE.md b/docs/PRD_GUIDE.md index 7313ceb..0a4464e 100644 --- a/docs/PRD_GUIDE.md +++ b/docs/PRD_GUIDE.md @@ -25,6 +25,13 @@ Create an intuitive, powerful task management tool that combines hierarchical or ### 🆕 Recent Updates +**Feature TM-066: Task Comments End-to-End** (✅ Completed) +- Backend endpoints for comments: list/create/update/delete +- RLS policies now include `SELECT`, `INSERT`, `UPDATE`, `DELETE` +- DB-backed cooldown with `429` + `Retry-After` header +- Frontend cooldown UX: disabled send button + countdown message +- Added pagination support (`limit`/`offset`) and improved error handling + **Feature TM-065: UX - Close Detail View with Escape** (✅ Completed) - Implemented global `Escape` key listener in `TaskDetailModal` for instant closure - Enhanced navigation to ensure return to Board or Tree view upon closing @@ -142,12 +149,12 @@ Create an intuitive, powerful task management tool that combines hierarchical or | **Password Reset** | ✅ Complete | 100% | Forgot password functionality | | **Account Settings** | ❌ Pending | 0% | User preferences and settings | -### 📱 Enhanced UI Features (Not Started - 25%) +### 📱 Enhanced UI Features (In Progress - 40%) | Feature | Status | Completion | Description | |---------|--------|------------|-------------| | **Task Detail View** | ✅ Complete | 100% | Detailed task view modal | -| **Task Comments** | ❌ Pending | 0% | Add comments to tasks | +| **Task Comments** | ✅ Complete | 100% | Create/edit/delete comments, backend cooldown (429), Retry-After UX countdown, and RLS-backed ownership controls | | **Task Attachments** | ✅ Complete | 100% | Attach files to tasks | | **Task Labels/Tags** | ❌ Pending | 0% | Categorize tasks with labels | | **Task Priority** | ❌ Pending | 0% | Set task priorities | diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 21ae1f8..15aa32e 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -79,6 +79,7 @@ src/test/ - ResetPasswordPage (7 tests) - **PasswordInput**: Componente de contraseña con toggle de visibilidad (8 tests) - **TaskForm**: Creación, edición, validación, IA, campos de Estimación y Responsable (22 tests) +- **TaskComments**: carga, estado vacío, submit, manejo de error, y prevención de stale state/race conditions (5 tests) - **TaskTimer**: Cronometraje, notificaciones (6 tests) - **useTasks**: Lógica de tareas y tiempo (10 tests) - **openaiService**: Integración IA (16 tests) diff --git a/e2e/page-objects/auth.page.ts b/e2e/page-objects/auth.page.ts index 3b0ed1d..d032455 100644 --- a/e2e/page-objects/auth.page.ts +++ b/e2e/page-objects/auth.page.ts @@ -20,8 +20,18 @@ export class AuthPage { // Login actions async login(email: string, password: string, expectSuccess: boolean = true) { - await this.page.locator('[data-testid="email-input"]').fill(email); - await this.page.locator('[data-testid="password-input"]').fill(password); + const emailInput = this.page.locator('[data-testid="email-input"]'); + const passwordInput = this.page.locator('[data-testid="password-input"]'); + + // Use fill instead of type for reliability with browser autofill + await emailInput.click(); + await emailInput.fill(email); + await expect(emailInput).toHaveValue(email); + + await passwordInput.click(); + await passwordInput.fill(password); + await expect(passwordInput).toHaveValue(password); + await this.page.locator('[data-testid="login-button"]').click(); if (expectSuccess) { diff --git a/e2e/task-comments.spec.ts b/e2e/task-comments.spec.ts new file mode 100644 index 0000000..f519c2f --- /dev/null +++ b/e2e/task-comments.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; +import { AppPage } from './page-objects/app.page'; +import { TaskPage } from './page-objects/task.page'; +import { AuthPage } from './page-objects/auth.page'; + +const generateUniqueTitle = (base: string) => `${base} - ${Date.now()}-${Math.floor(Math.random() * 1000)}`; + +test.describe('Task Comments', () => { + let appPage: AppPage; + let taskPage: TaskPage; + let authPage: AuthPage; + + test.beforeEach(async ({ page }) => { + appPage = new AppPage(page); + taskPage = new TaskPage(page); + authPage = new AuthPage(page); + + await appPage.goto(); + // Clear state + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + await authPage.goToLogin(); + + // Slow down typing to avoid race conditions + const email = process.env.E2E_USER_TASK_EMAIL || 'automation-kolium-task@yopmail.com'; + const password = process.env.E2E_USER_TASK_PASSWORD || 'Automation123'; + + await authPage.login(email, password); + + // Wait for the app to be fully loaded + await appPage.waitForLoadingComplete(); + }); + + test('should add and view comments on a task', async ({ page }) => { + test.setTimeout(60000); // Increase timeout for this test + const taskTitle = generateUniqueTitle('Comment Task'); + + await appPage.openAddTaskModal(); + await taskPage.createTask({ title: taskTitle, description: 'Task for comments' }); + + // Search for the task to ensure it's visible + await appPage.searchTasks(taskTitle); + const taskCard = page.getByText(taskTitle).first(); + await expect(taskCard).toBeVisible({ timeout: 10000 }); + + // Open task detail + await taskCard.click(); + + // Check comments section - wait for it to be visible + const commentsHeader = page.locator('h3').filter({ hasText: 'Comments' }); + await expect(commentsHeader).toBeVisible({ timeout: 10000 }); + + // Wait for loading to finish or empty message + const emptyMsg = page.getByText('No comments yet.').first(); + const loadingMsg = page.getByText('Loading...').first(); + + await expect(loadingMsg.or(emptyMsg)).toBeVisible(); + + // Add comment + const commentContent = 'This is a test comment from E2E - ' + Date.now(); + const textarea = page.getByTestId('comment-input'); + await textarea.click(); + await textarea.fill(commentContent); + + // Check if button is enabled + const sendButton = page.getByTestId('add-comment-button'); + await expect(sendButton).toBeEnabled(); + + await sendButton.click(); + + // Verify comment + await expect(page.getByText(commentContent)).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('No comments yet.')).not.toBeVisible(); + + // persistence check + await page.locator('button[aria-label="Close modal"]').click(); + await appPage.clearSearch(); + await appPage.searchTasks(taskTitle); + await page.getByText(taskTitle).first().click(); + await expect(page.getByText(commentContent)).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/migrations/001_create_task_comments.sql b/migrations/001_create_task_comments.sql new file mode 100644 index 0000000..0a22e42 --- /dev/null +++ b/migrations/001_create_task_comments.sql @@ -0,0 +1,82 @@ +-- Migration: Create task_comments table +-- Run this in Supabase SQL Editor + +CREATE TABLE IF NOT EXISTS public.task_comments ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + task_id bigint NOT NULL REFERENCES public.tasks(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id), + author_name text NOT NULL, + author_avatar text, + content text NOT NULL, + created_at timestamp with time zone DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, + updated_at timestamp with time zone DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL +); + +ALTER TABLE public.task_comments + ADD COLUMN IF NOT EXISTS updated_at timestamp with time zone DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_task_comments_task_id + ON public.task_comments(task_id); + +CREATE INDEX IF NOT EXISTS idx_task_comments_user_id + ON public.task_comments(user_id); + +-- Enable Row Level Security +ALTER TABLE public.task_comments ENABLE ROW LEVEL SECURITY; + +-- Policies +CREATE POLICY "Users can view comments on their own tasks" + ON public.task_comments + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.tasks + WHERE public.tasks.id = public.task_comments.task_id + AND public.tasks.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can create comments on their own tasks" + ON public.task_comments + FOR INSERT + WITH CHECK ( + auth.uid() = user_id + AND EXISTS ( + SELECT 1 FROM public.tasks + WHERE public.tasks.id = public.task_comments.task_id + AND public.tasks.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can update their own comments" + ON public.task_comments + FOR UPDATE + USING ( + auth.uid() = user_id + ) + WITH CHECK ( + auth.uid() = user_id + ); + +CREATE POLICY "Users can delete their own comments" + ON public.task_comments + FOR DELETE + USING ( + auth.uid() = user_id + ); + +CREATE OR REPLACE FUNCTION public.set_task_comments_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = TIMEZONE('utc'::text, NOW()); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_task_comments_updated_at ON public.task_comments; +CREATE TRIGGER trg_task_comments_updated_at +BEFORE UPDATE ON public.task_comments +FOR EACH ROW +EXECUTE FUNCTION public.set_task_comments_updated_at(); diff --git a/src/components/TaskComments.tsx b/src/components/TaskComments.tsx new file mode 100644 index 0000000..2d0cae0 --- /dev/null +++ b/src/components/TaskComments.tsx @@ -0,0 +1,289 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TaskComment } from '../types/Task'; +import { taskService } from '../services/taskService'; +import { useTheme } from '../contexts/ThemeContext'; +import { MessageSquare, Send, User } from 'lucide-react'; +import { toast } from 'sonner'; +import { formatDate } from '../utils/taskUtils'; +import supabase from '../lib/supabaseClient'; + +interface TaskCommentsProps { + taskId: string; +} + +export const TaskComments: React.FC = ({ taskId }) => { + const { t } = useTranslation(); + const { theme } = useTheme(); + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [cooldownUntil, setCooldownUntil] = useState(null); + const [cooldownSeconds, setCooldownSeconds] = useState(0); + const [currentUserId, setCurrentUserId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + const [isSavingEdit, setIsSavingEdit] = useState(false); + const requestSeqRef = useRef(0); + const MAX_COMMENT_LENGTH = 2000; + + const fetchComments = async () => { + const requestId = ++requestSeqRef.current; + + setIsLoading(true); + try { + const response = await taskService.getComments(taskId); + if (requestId !== requestSeqRef.current) return; + + if (response.error) { + toast.error(response.error); + return; + } + + if (response.data) { + setComments(response.data); + } + } catch (error) { + if (requestId !== requestSeqRef.current) return; + console.error('Error fetching comments:', error); + toast.error(t('common.error', 'Something went wrong')); + } finally { + if (requestId === requestSeqRef.current) { + setIsLoading(false); + } + } + }; + + useEffect(() => { + fetchComments(); + + supabase.auth.getUser().then(({ data }) => { + setCurrentUserId(data.user?.id || null); + }); + + return () => { + requestSeqRef.current += 1; + }; + }, [taskId]); + + useEffect(() => { + if (!cooldownUntil) { + setCooldownSeconds(0); + return; + } + + const tick = () => { + const remainingMs = cooldownUntil - Date.now(); + if (remainingMs <= 0) { + setCooldownUntil(null); + setCooldownSeconds(0); + return; + } + setCooldownSeconds(Math.ceil(remainingMs / 1000)); + }; + + tick(); + const timer = window.setInterval(tick, 250); + return () => window.clearInterval(timer); + }, [cooldownUntil]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newComment.trim(); + if (!trimmed || isSubmitting) return; + + setIsSubmitting(true); + try { + const response = await taskService.addComment(taskId, trimmed); + if (response.error) { + if (response.retryAfterSeconds) { + const seconds = Math.max(1, Number(response.retryAfterSeconds)); + setCooldownUntil(Date.now() + seconds * 1000); + } + toast.error(response.error); + return; + } + + if (response.data) { + setComments((prev) => [...prev, response.data!]); + setNewComment(''); + } + } catch (error) { + console.error('Error adding comment:', error); + toast.error(t('common.error', 'Something went wrong')); + } finally { + setIsSubmitting(false); + } + }; + + const beginEdit = (comment: TaskComment) => { + setEditingId(comment.id); + setEditingContent(comment.content); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditingContent(''); + }; + + const saveEdit = async () => { + if (!editingId || isSavingEdit) return; + const trimmed = editingContent.trim(); + if (!trimmed) return; + + setIsSavingEdit(true); + try { + const response = await taskService.updateComment(taskId, editingId, trimmed); + if (response.error) { + toast.error(response.error); + return; + } + + if (response.data) { + setComments((prev) => prev.map((c) => (c.id === editingId ? response.data! : c))); + cancelEdit(); + } + } finally { + setIsSavingEdit(false); + } + }; + + const removeComment = async (commentId: string) => { + if (!window.confirm(t('tasks.confirm_delete_comment', 'Delete this comment?'))) return; + + const response = await taskService.deleteComment(taskId, commentId); + if (response.error) { + toast.error(response.error); + return; + } + + setComments((prev) => prev.filter((c) => c.id !== commentId)); + }; + + return ( +
+
+ +

+ {t('tasks.comments', 'Comments')} +

+
+ + {/* Comment List */} +
+ {isLoading ? ( +

{t('common.loading')}

+ ) : comments.length === 0 ? ( +

{t('tasks.no_comments', 'No comments yet.')}

+ ) : ( + comments.map((comment) => ( +
+
+
+
+ {comment.authorAvatar ? ( + {comment.authorName} + ) : ( + + )} +
+ + {comment.authorName} + +
+
+ + {formatDate(comment.updatedAt || comment.createdAt)} + + {comment.userId === currentUserId && ( +
+ + +
+ )} +
+
+ {editingId === comment.id ? ( +
+