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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.11.3] - 2026-10-14
|
||||||
### Fixed
|
### Fixed
|
||||||
- Popup de actividad: los badges de "Comentarios" y "Documentos" se quedaban pegados en el
|
- Popup de actividad: los badges de "Comentarios" y "Documentos" se quedaban pegados en el
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.11.3",
|
"version": "2.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.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 } });
|
await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } });
|
||||||
ok(res, comment, 201);
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { pool } from '../../config/db.js';
|
|||||||
|
|
||||||
export async function findByEntity(entityType, entityId) {
|
export async function findByEntity(entityType, entityId) {
|
||||||
const [rows] = await pool.query(
|
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
|
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`,
|
WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`,
|
||||||
[entityType, entityId]
|
[entityType, entityId]
|
||||||
@@ -10,6 +10,24 @@ export async function findByEntity(entityType, entityId) {
|
|||||||
return rows;
|
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
|
// 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.
|
// no hay FK fisica cruzada (entity_type/entity_id es polimorfico) que lo haga por cascada.
|
||||||
export async function removeByEntity(entityType, entityId) {
|
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 (?, ?, ?, ?)',
|
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
|
||||||
[entityType, entityId, userId, message]
|
[entityType, entityId, userId, message]
|
||||||
);
|
);
|
||||||
const [rows] = await pool.query(
|
return findById(result.insertId);
|
||||||
`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];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import { getComments, postComment } from './controller.js';
|
import { getComments, postComment, putComment, deleteComment } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/', getComments);
|
router.get('/', getComments);
|
||||||
router.post('/', postComment);
|
router.post('/', postComment);
|
||||||
|
router.put('/:id', putComment);
|
||||||
|
router.delete('/:id', deleteComment);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.11.3",
|
"version": "2.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { useState } from 'react';
|
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 { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../hooks/useAsync.js';
|
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';
|
import { Button } from './Button.jsx';
|
||||||
|
|
||||||
|
const MANAGE_ROLES = ['superadmin', 'admin'];
|
||||||
|
|
||||||
export function CommentsPanel({ entityType, entityId, onChanged }) {
|
export function CommentsPanel({ entityType, entityId, onChanged }) {
|
||||||
const { t } = useUi();
|
const { t, openDialog } = useUi();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
|
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editingText, setEditingText] = useState('');
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (!message.trim()) return;
|
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 <div className="text-muted">{t('common_loading')}</div>;
|
if (!comments) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,8 +76,32 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
|
|||||||
{comments.length === 0 && <div className="text-muted" style={{ marginBottom: 12 }}>{t('common_empty')}</div>}
|
{comments.length === 0 && <div className="text-muted" style={{ marginBottom: 12 }}>{t('common_empty')}</div>}
|
||||||
{comments.map((c) => (
|
{comments.map((c) => (
|
||||||
<div key={c.id} style={{ padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
|
<div key={c.id} style={{ padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
|
||||||
<div style={{ fontSize: '.78rem', fontWeight: 700 }}>{c.user_name} <span className="text-muted" style={{ fontWeight: 400 }}>· {c.created_at}</span></div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ fontSize: '.82rem', marginTop: 2 }}>{c.message}</div>
|
<div style={{ fontSize: '.78rem', fontWeight: 700, flex: 1 }}>
|
||||||
|
{c.user_name} <span className="text-muted" style={{ fontWeight: 400 }}>· {c.created_at}{c.updated_at !== c.created_at ? ` · ${t('notes_updated')}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
{editingId !== c.id && canManage(c) && (
|
||||||
|
<>
|
||||||
|
<button className="icon-btn" title={t('common_edit')} onClick={() => startEdit(c)}><Pencil size={14} /></button>
|
||||||
|
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(c)}><Trash2 size={14} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingId === c.id ? (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<input
|
||||||
|
className="input" value={editingText} autoFocus
|
||||||
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
|
||||||
|
<Button variant="accent" size="sm" icon={Save} onClick={() => saveEdit(c.id)}>{t('common_save')}</Button>
|
||||||
|
<Button variant="standard" size="sm" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '.82rem', marginTop: 2 }}>{c.message}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ import { api } from './api.js';
|
|||||||
|
|
||||||
export const listComments = (entityType, entityId) => api.get(`/comments?entityType=${entityType}&entityId=${entityId}`);
|
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 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}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user