From fbc6a379595bf4420eafc8599c459a17d2776e2d Mon Sep 17 00:00:00 2001 From: kelvin calcano Date: Thu, 12 Feb 2026 18:42:23 +0100 Subject: [PATCH 01/28] feat: add task comments - Add comments UI in task modal\n- Add comments API endpoints + types\n- Add SQL migration for task_comments + RLS policies\n- Add Playwright E2E + component tests\n- Update DB schema docs --- backend/src/controllers/taskController.js | 100 ++++++++++++++- backend/src/routes/tasks.js | 57 ++++++++- docs/DATABASE_SCHEMA.md | 53 ++++++++ e2e/page-objects/auth.page.ts | 14 ++- e2e/task-comments.spec.ts | 83 +++++++++++++ migrations/001_create_task_comments.sql | 45 +++++++ src/components/TaskComments.tsx | 142 ++++++++++++++++++++++ src/components/TaskDetailModal.tsx | 7 ++ src/locales/en.json | 5 +- src/locales/es.json | 5 +- src/services/taskService.ts | 66 +++++++++- src/test/components/TaskComments.test.tsx | 101 +++++++++++++++ src/test/setup.ts | 4 + src/types/Task.ts | 10 ++ 14 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 e2e/task-comments.spec.ts create mode 100644 migrations/001_create_task_comments.sql create mode 100644 src/components/TaskComments.tsx create mode 100644 src/test/components/TaskComments.test.tsx diff --git a/backend/src/controllers/taskController.js b/backend/src/controllers/taskController.js index 1426298..2031235 100644 --- a/backend/src/controllers/taskController.js +++ b/backend/src/controllers/taskController.js @@ -377,10 +377,108 @@ 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 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('*') + .eq('task_id', task_id) + .order('created_at', { ascending: true }); + + 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' }); + } +}; + +/** + * 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 (!content || !content.trim()) { + return res.status(400).json({ error: 'Content is required' }); + } + + 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' }); + } + + // Get user profile for author info + const { data: profile } = await db + .from('profiles') + .select('username, display_name, avatar_url') + .eq('id', user_id) + .single(); + + const author_name = profile?.display_name || profile?.username || req.user.email; + 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: content.trim() + }]) + .select() + .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' }); + } +}; + + module.exports = { createTask, getTasks, getTaskById, updateTask, - deleteTask + deleteTask, + getComments, + addComment }; diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index d79954a..d05f405 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -6,7 +6,9 @@ const { getTasks, getTaskById, updateTask, - deleteTask + deleteTask, + getComments, + addComment } = require('../controllers/taskController'); // Apply authentication middleware to all task routes @@ -151,4 +153,57 @@ 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); + module.exports = router; diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index f4d2575..6da57bb 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,45 @@ 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 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() + ) + ); + +-- 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/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..6caddc7 --- /dev/null +++ b/migrations/001_create_task_comments.sql @@ -0,0 +1,45 @@ +-- 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 +); + +-- 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 ( + 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 delete their own comments" + ON public.task_comments + FOR DELETE + USING ( + auth.uid() = user_id + ); diff --git a/src/components/TaskComments.tsx b/src/components/TaskComments.tsx new file mode 100644 index 0000000..9bd7932 --- /dev/null +++ b/src/components/TaskComments.tsx @@ -0,0 +1,142 @@ +import React, { useState, useEffect } 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 { formatDate } from '../utils/taskUtils'; + +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 fetchComments = async () => { + setIsLoading(true); + try { + const response = await taskService.getComments(taskId); + if (response.data) { + setComments(response.data); + } + } catch (error) { + console.error('Error fetching comments:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchComments(); + }, [taskId]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newComment.trim() || isSubmitting) return; + + setIsSubmitting(true); + try { + const response = await taskService.addComment(taskId, newComment); + if (response.data) { + setComments((prev) => [...prev, response.data!]); + setNewComment(''); + } + } catch (error) { + console.error('Error adding comment:', error); + } finally { + setIsSubmitting(false); + } + }; + + 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.createdAt)} + +
+

+ {comment.content} +

+
+ )) + )} +
+ + {/* New Comment Form */} +
+