From 3257c4f065f716ae7136814443472268780708cd Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 14 Oct 2026 12:22:27 -0500 Subject: [PATCH] feat: version 2.12.0 -- editar y borrar comentarios Hasta ahora un comentario (de proyecto o actividad) solo se podia crear, nunca tocar. Se agrega PUT/DELETE /comments/:id, restringido a quien lo escribio o a superadmin/admin (igual criterio que useria para notas/tareas: nunca otro colaborador aunque tenga permiso de escritura en el proyecto). Migracion 021 agrega updated_at a comments para mostrar "Editado" igual que ya hace Notas. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++ VERSION | 2 +- .../database/migrations/021_comments_edit.sql | 3 + backend/package.json | 2 +- backend/src/modules/comments/controller.js | 26 ++++++ backend/src/modules/comments/repository.js | 27 ++++-- backend/src/modules/comments/routes.js | 4 +- frontend/package.json | 2 +- frontend/src/components/CommentsPanel.jsx | 82 +++++++++++++++++-- frontend/src/services/comments.js | 2 + 10 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 backend/database/migrations/021_comments_edit.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c4824..722ae4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog - project.linkedcorp.net +## [2.12.0] - 2026-10-14 +### Added +- Comentarios (de proyectos y actividades): ahora se pueden editar y borrar, solo quien lo + escribió (o superadmin/admin) — antes solo se podían crear. Un comentario editado muestra + "Editado" junto a la fecha, igual que ya hacía Notas. + ## [2.11.3] - 2026-10-14 ### Fixed - Popup de actividad: los badges de "Comentarios" y "Documentos" se quedaban pegados en el diff --git a/VERSION b/VERSION index 22e3b6b..d8b6989 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.11.3 +2.12.0 diff --git a/backend/database/migrations/021_comments_edit.sql b/backend/database/migrations/021_comments_edit.sql new file mode 100644 index 0000000..0b490f4 --- /dev/null +++ b/backend/database/migrations/021_comments_edit.sql @@ -0,0 +1,3 @@ +-- Permite editar un comentario (antes solo se creaban, nunca se tocaban). updated_at deja ver +-- "Editado" en el frontend igual que ya hace Notas. Idempotente. +ALTER TABLE comments ADD COLUMN IF NOT EXISTS updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; diff --git a/backend/package.json b/backend/package.json index 2263486..a7b2309 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.11.3", + "version": "2.12.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/comments/controller.js b/backend/src/modules/comments/controller.js index d05f03a..9451f2a 100644 --- a/backend/src/modules/comments/controller.js +++ b/backend/src/modules/comments/controller.js @@ -33,3 +33,29 @@ export const postComment = asyncHandler(async (req, res) => { await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } }); ok(res, comment, 201); }); + +// Solo el autor, o superadmin/admin, puede editar/borrar -- nunca otro colaborador aunque tenga +// permiso de escritura en el proyecto (un comentario es responsabilidad de quien lo escribio). +function canManageComment(currentUser, comment) { + return comment.user_id === currentUser.id || [ROLES.SUPERADMIN, ROLES.ADMIN].includes(currentUser.roleCode); +} + +export const putComment = asyncHandler(async (req, res) => { + const comment = await repo.findById(Number(req.params.id)); + if (!comment) throw new HttpError(404, 'Comentario no encontrado'); + if (!canManageComment(req.currentUser, comment)) throw new HttpError(403, 'Solo quien escribio el comentario puede editarlo'); + const message = (req.body?.message || '').trim(); + if (!message) throw new HttpError(400, 'El mensaje es obligatorio'); + const updated = await repo.update(comment.id, message); + await logAudit({ userId: req.currentUser.id, action: 'comment_edit', entityType: comment.entity_type, entityId: comment.entity_id, detail: { commentId: comment.id } }); + ok(res, updated); +}); + +export const deleteComment = asyncHandler(async (req, res) => { + const comment = await repo.findById(Number(req.params.id)); + if (!comment) throw new HttpError(404, 'Comentario no encontrado'); + if (!canManageComment(req.currentUser, comment)) throw new HttpError(403, 'Solo quien escribio el comentario puede borrarlo'); + await repo.remove(comment.id); + await logAudit({ userId: req.currentUser.id, action: 'comment_delete', entityType: comment.entity_type, entityId: comment.entity_id, detail: { commentId: comment.id } }); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/comments/repository.js b/backend/src/modules/comments/repository.js index 3e2f5cb..b69f529 100644 --- a/backend/src/modules/comments/repository.js +++ b/backend/src/modules/comments/repository.js @@ -2,7 +2,7 @@ import { pool } from '../../config/db.js'; export async function findByEntity(entityType, entityId) { const [rows] = await pool.query( - `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at + `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at, c.updated_at FROM comments c JOIN users u ON u.id = c.user_id WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`, [entityType, entityId] @@ -10,6 +10,24 @@ export async function findByEntity(entityType, entityId) { return rows; } +export async function findById(id) { + const [rows] = await pool.query( + `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at, c.updated_at + FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`, + [id] + ); + return rows[0] || null; +} + +export async function update(id, message) { + await pool.query('UPDATE comments SET message = ? WHERE id = ?', [message, id]); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM comments WHERE id = ?', [id]); +} + // Usado al borrar la entidad duena (actividad, proyecto): limpia sus comentarios de paso, ya que // no hay FK fisica cruzada (entity_type/entity_id es polimorfico) que lo haga por cascada. export async function removeByEntity(entityType, entityId) { @@ -27,10 +45,5 @@ export async function create({ entityType, entityId, userId, message }) { 'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)', [entityType, entityId, userId, message] ); - const [rows] = await pool.query( - `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at - FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`, - [result.insertId] - ); - return rows[0]; + return findById(result.insertId); } diff --git a/backend/src/modules/comments/routes.js b/backend/src/modules/comments/routes.js index 456eff2..bfea514 100644 --- a/backend/src/modules/comments/routes.js +++ b/backend/src/modules/comments/routes.js @@ -1,11 +1,13 @@ import { Router } from 'express'; import { requireAuth } from '../../middleware/auth.js'; -import { getComments, postComment } from './controller.js'; +import { getComments, postComment, putComment, deleteComment } from './controller.js'; const router = Router(); router.use(requireAuth); router.get('/', getComments); router.post('/', postComment); +router.put('/:id', putComment); +router.delete('/:id', deleteComment); export default router; diff --git a/frontend/package.json b/frontend/package.json index fc714a6..a2317b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.11.3", + "version": "2.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/CommentsPanel.jsx b/frontend/src/components/CommentsPanel.jsx index f24f6d2..9c242d5 100644 --- a/frontend/src/components/CommentsPanel.jsx +++ b/frontend/src/components/CommentsPanel.jsx @@ -1,15 +1,21 @@ import { useState } from 'react'; -import { Send } from 'lucide-react'; +import { Send, Pencil, Trash2, Save, X } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; +import { useAuth } from '../context/auth-context.jsx'; import { useAsync, useAction } from '../hooks/useAsync.js'; -import { listComments, createComment } from '../services/comments.js'; +import { listComments, createComment, updateComment, deleteComment } from '../services/comments.js'; import { Button } from './Button.jsx'; +const MANAGE_ROLES = ['superadmin', 'admin']; + export function CommentsPanel({ entityType, entityId, onChanged }) { - const { t } = useUi(); + const { t, openDialog } = useUi(); + const { user: currentUser } = useAuth(); const runAction = useAction(); const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]); const [message, setMessage] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingText, setEditingText] = useState(''); async function send() { if (!message.trim()) return; @@ -21,6 +27,48 @@ export function CommentsPanel({ entityType, entityId, onChanged }) { }); } + function canManage(c) { + return c.user_id === currentUser?.id || MANAGE_ROLES.includes(currentUser?.roleCode); + } + + function startEdit(c) { + setEditingId(c.id); + setEditingText(c.message); + } + + function cancelEdit() { + setEditingId(null); + setEditingText(''); + } + + async function saveEdit(id) { + if (!editingText.trim()) return; + await runAction(async () => { + await updateComment(id, editingText.trim()); + cancelEdit(); + await reload(); + }); + } + + function confirmDelete(c) { + openDialog({ + tone: 'warning', + title: t('common_confirm_delete_title'), + message: t('common_confirm_delete_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_delete'), variant: 'danger', + onClick: () => runAction(async () => { + await deleteComment(c.id); + await reload(); + onChanged?.(); + }), + }, + ], + }); + } + if (!comments) return
{t('common_loading')}
; return ( @@ -28,8 +76,32 @@ export function CommentsPanel({ entityType, entityId, onChanged }) { {comments.length === 0 &&
{t('common_empty')}
} {comments.map((c) => (
-
{c.user_name} · {c.created_at}
-
{c.message}
+
+
+ {c.user_name} · {c.created_at}{c.updated_at !== c.created_at ? ` · ${t('notes_updated')}` : ''} +
+ {editingId !== c.id && canManage(c) && ( + <> + + + + )} +
+ {editingId === c.id ? ( +
+ setEditingText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }} + /> +
+ + +
+
+ ) : ( +
{c.message}
+ )}
))}
diff --git a/frontend/src/services/comments.js b/frontend/src/services/comments.js index 8c064ba..6ab24f5 100644 --- a/frontend/src/services/comments.js +++ b/frontend/src/services/comments.js @@ -2,3 +2,5 @@ import { api } from './api.js'; export const listComments = (entityType, entityId) => api.get(`/comments?entityType=${entityType}&entityId=${entityId}`); export const createComment = (entityType, entityId, message) => api.post('/comments', { entityType, entityId, message }); +export const updateComment = (id, message) => api.put(`/comments/${id}`, { message }); +export const deleteComment = (id) => api.delete(`/comments/${id}`);