diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 046a767..7c0a86e 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -39,7 +39,8 @@ | `notifications` | notificaciones in-app por usuario | | `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) | | `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración | -| `notes` | apuntes rápidos personales, siempre privados de cada usuario, menú propio "Notas" | +| `notes` | apuntes rápidos personales, menú propio "Notas"; editable solo por el dueño | +| `note_shares` | con quién está compartida (solo lectura) cada nota — `note_id, user_id` | | `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas | ## Relaciones clave diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a6c45..dece201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog - project.linkedcorp.net +## [2.10.0] - 2026-10-12 +### Added +- Rediseño del módulo "Notas": ahora se ven como tarjetas (resumen del contenido), con una + primera tarjeta fija de "+" para crear — crear y editar se hacen en un popup, ya no inline. +- Compartir una nota con otro usuario ya registrado, siempre de solo lectura: el dueño sigue + siendo el único que la edita, borra o administra a quién está compartida; quien la recibe la + ve con un aviso "Compartida por {nombre}" y sin ningún control de edición. + ## [2.9.1] - 2026-10-07 ### Added - Los bloques de código dentro de un .md visto en Documentos ahora tienen resaltado de sintaxis diff --git a/VERSION b/VERSION index dedcc7d..10c2c0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.9.1 +2.10.0 diff --git a/backend/database/migrations/019_notes_sharing.sql b/backend/database/migrations/019_notes_sharing.sql new file mode 100644 index 0000000..4a69d7f --- /dev/null +++ b/backend/database/migrations/019_notes_sharing.sql @@ -0,0 +1,12 @@ +-- Compartir una nota con otro usuario, siempre de solo lectura (el dueño sigue siendo el unico +-- que edita/borra/comparte). Analogo simplificado de project_members (sin columna de rol, sin +-- invitacion por correo). Idempotente. + +CREATE TABLE IF NOT EXISTS note_shares ( + note_id INT NOT NULL, + user_id INT NOT NULL, + shared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (note_id, user_id), + CONSTRAINT fk_note_shares_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + CONSTRAINT fk_note_shares_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index 2eaf13e..f6d0c34 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.9.1", + "version": "2.10.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/notes/controller.js b/backend/src/modules/notes/controller.js index 8bf6edf..e40fb19 100644 --- a/backend/src/modules/notes/controller.js +++ b/backend/src/modules/notes/controller.js @@ -17,3 +17,15 @@ export const deleteNote = asyncHandler(async (req, res) => { await service.removeNote(Number(req.params.id), req.currentUser); ok(res, { deleted: true }); }); + +export const getNoteShares = asyncHandler(async (req, res) => { + ok(res, await service.listNoteShares(Number(req.params.id), req.currentUser)); +}); + +export const postNoteShare = asyncHandler(async (req, res) => { + ok(res, await service.shareNote(Number(req.params.id), Number(req.body.userId), req.currentUser), 201); +}); + +export const deleteNoteShare = asyncHandler(async (req, res) => { + ok(res, await service.unshareNote(Number(req.params.id), Number(req.params.userId), req.currentUser)); +}); diff --git a/backend/src/modules/notes/repository.js b/backend/src/modules/notes/repository.js index 5eb8b30..ca64b58 100644 --- a/backend/src/modules/notes/repository.js +++ b/backend/src/modules/notes/repository.js @@ -1,11 +1,18 @@ import { pool } from '../../config/db.js'; -export async function findByUser(userId) { +// Notas propias MAS las compartidas conmigo. is_owner permite al frontend decidir si se puede +// editar/borrar/compartir (siempre false para una nota compartida: el dueno nunca cambia). +export async function findVisibleToUser(userId) { const [rows] = await pool.query( - 'SELECT id, user_id, content, created_at, updated_at FROM notes WHERE user_id = ? ORDER BY updated_at DESC', - [userId] + `SELECT n.id, n.user_id, n.content, n.created_at, n.updated_at, + (n.user_id = ?) AS is_owner, u.name AS owner_name + FROM notes n + JOIN users u ON u.id = n.user_id + WHERE n.user_id = ? OR n.id IN (SELECT note_id FROM note_shares WHERE user_id = ?) + ORDER BY n.updated_at DESC`, + [userId, userId, userId] ); - return rows; + return rows.map((r) => ({ ...r, is_owner: !!r.is_owner })); } export async function findById(id) { @@ -26,3 +33,28 @@ export async function update(id, content) { export async function remove(id) { await pool.query('DELETE FROM notes WHERE id = ?', [id]); } + +export async function listShares(noteId) { + const [rows] = await pool.query( + `SELECT ns.user_id, u.name, u.email, ns.shared_at + FROM note_shares ns JOIN users u ON u.id = ns.user_id + WHERE ns.note_id = ? ORDER BY u.name`, + [noteId] + ); + return rows; +} + +export async function isSharedWith(noteId, userId) { + const [rows] = await pool.query('SELECT 1 FROM note_shares WHERE note_id = ? AND user_id = ?', [noteId, userId]); + return rows.length > 0; +} + +export async function addShare(noteId, userId) { + await pool.query('INSERT IGNORE INTO note_shares (note_id, user_id) VALUES (?, ?)', [noteId, userId]); + return listShares(noteId); +} + +export async function removeShare(noteId, userId) { + await pool.query('DELETE FROM note_shares WHERE note_id = ? AND user_id = ?', [noteId, userId]); + return listShares(noteId); +} diff --git a/backend/src/modules/notes/routes.js b/backend/src/modules/notes/routes.js index 0ba398f..3e1c924 100644 --- a/backend/src/modules/notes/routes.js +++ b/backend/src/modules/notes/routes.js @@ -1,14 +1,18 @@ import { Router } from 'express'; import { requireAuth } from '../../middleware/auth.js'; -import { getNotes, postNote, putNote, deleteNote } from './controller.js'; +import { getNotes, postNote, putNote, deleteNote, getNoteShares, postNoteShare, deleteNoteShare } from './controller.js'; const router = Router(); -// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas. +// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas (y ve las que +// otros compartieron con el). Los permisos de compartir se resuelven en el service (dueño-only). router.use(requireAuth); router.get('/', getNotes); router.post('/', postNote); router.put('/:id', putNote); router.delete('/:id', deleteNote); +router.get('/:id/shares', getNoteShares); +router.post('/:id/shares', postNoteShare); +router.delete('/:id/shares/:userId', deleteNoteShare); export default router; diff --git a/backend/src/modules/notes/service.js b/backend/src/modules/notes/service.js index b2f8eb4..a1be4b7 100644 --- a/backend/src/modules/notes/service.js +++ b/backend/src/modules/notes/service.js @@ -1,15 +1,17 @@ import * as repo from './repository.js'; +import * as usersRepo from '../users/repository.js'; import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; -// Siempre privadas: cada quien ve y toca solo sus propias notas, sin importar el rol. Una nota -// de otro usuario responde 404 (no 403) para no revelar que existe. +// Siempre privadas para editar/borrar/compartir: cada quien solo toca sus propias notas, sin +// importar el rol. Una nota de otro usuario responde 404 (no 403) para no revelar que existe. async function getOwnNoteOr404(id, currentUser) { const note = await repo.findById(id); if (!note || note.user_id !== currentUser.id) throw new HttpError(404, 'Nota no encontrada'); return note; } -export const listNotes = (currentUser) => repo.findByUser(currentUser.id); +export const listNotes = (currentUser) => repo.findVisibleToUser(currentUser.id); export async function createNote(content, currentUser) { const trimmed = (content || '').trim(); @@ -28,3 +30,26 @@ export async function removeNote(id, currentUser) { await getOwnNoteOr404(id, currentUser); await repo.remove(id); } + +export async function listNoteShares(id, currentUser) { + await getOwnNoteOr404(id, currentUser); + return repo.listShares(id); +} + +export async function shareNote(id, targetUserId, currentUser) { + await getOwnNoteOr404(id, currentUser); + if (!targetUserId) throw new HttpError(400, 'Indica un usuario'); + if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir una nota contigo mismo'); + const targetUser = await usersRepo.findById(targetUserId); + if (!targetUser) throw new HttpError(404, 'Usuario no encontrado'); + const shares = await repo.addShare(id, targetUserId); + await logAudit({ userId: currentUser.id, action: 'share', entityType: 'note', entityId: id, detail: { userId: targetUserId } }); + return shares; +} + +export async function unshareNote(id, targetUserId, currentUser) { + await getOwnNoteOr404(id, currentUser); + const shares = await repo.removeShare(id, targetUserId); + await logAudit({ userId: currentUser.id, action: 'unshare', entityType: 'note', entityId: id, detail: { userId: targetUserId } }); + return shares; +} diff --git a/frontend/package.json b/frontend/package.json index 178d38f..9b4668a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.9.1", + "version": "2.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 2b415f9..11e09a1 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -221,9 +221,14 @@ export const messages = { vault_auto_lock_notice: 'Saliste de Contraseñas por seguridad — ingresa tu PIN de nuevo para volver a entrar.', notes_title: 'Notas', notes_placeholder: 'Escribe un apunte rápido...', - notes_add: 'Agregar', notes_empty: 'No tienes notas todavía.', notes_updated: 'Editado', + notes_new: 'Nueva nota', + notes_share_title: 'Compartir', + notes_share_add: 'Compartir con', + notes_share_placeholder: 'Elegir usuario', + notes_share_empty: 'No compartida con nadie.', + notes_shared_by: 'Compartida por', recurring_task_new: 'Nueva tarea recurrente', recurring_task_project: 'Proyecto de origen', recurring_task_every: 'Cada', @@ -556,9 +561,14 @@ export const messages = { vault_auto_lock_notice: 'You left Passwords for security — enter your PIN again to come back in.', notes_title: 'Notes', notes_placeholder: 'Write a quick note...', - notes_add: 'Add', notes_empty: 'You don\'t have any notes yet.', notes_updated: 'Edited', + notes_new: 'New note', + notes_share_title: 'Share', + notes_share_add: 'Share with', + notes_share_placeholder: 'Choose a user', + notes_share_empty: 'Not shared with anyone.', + notes_shared_by: 'Shared by', recurring_task_new: 'New recurring task', recurring_task_project: 'Source project', recurring_task_every: 'Every', diff --git a/frontend/src/pages/Notas/NotasPage.jsx b/frontend/src/pages/Notas/NotasPage.jsx index 556f4a6..2bdfcf3 100644 --- a/frontend/src/pages/Notas/NotasPage.jsx +++ b/frontend/src/pages/Notas/NotasPage.jsx @@ -1,9 +1,15 @@ import { useState } from 'react'; -import { Plus, Pencil, Save, X, Trash2 } from 'lucide-react'; +import { Plus, Save, Trash2, Share2, UserPlus } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; -import { listNotes, createNote, updateNote, deleteNote } from '../../services/notes.js'; +import { + listNotes, createNote, updateNote, deleteNote, listNoteShares, addNoteShare, removeNoteShare, +} from '../../services/notes.js'; +import { listUsers } from '../../services/users.js'; import { Button } from '../../components/Button.jsx'; +import { Field } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; function formatDateTime(value) { return String(value).slice(0, 16).replace('T', ' '); @@ -13,40 +19,49 @@ export default function NotasPage() { const { t, pushToast, openDialog } = useUi(); const runAction = useAction(); const { data: notes, reload } = useAsync(listNotes, []); + const { data: users } = useAsync(listUsers, []); - const [newContent, setNewContent] = useState(''); - const [editingId, setEditingId] = useState(null); - const [editingContent, setEditingContent] = useState(''); + const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado) + const [creating, setCreating] = useState(false); + const [content, setContent] = useState(''); + const [shares, setShares] = useState([]); + const [sharingWith, setSharingWith] = useState(''); - async function addNote() { - if (!newContent.trim()) return; + function openCreate() { + setSelected(null); + setCreating(true); + setContent(''); + setShares([]); + setSharingWith(''); + } + + async function openNote(note) { + setSelected(note); + setCreating(false); + setContent(note.content); + setSharingWith(''); + setShares(note.is_owner ? await listNoteShares(note.id) : []); + } + + function closePopup() { + setSelected(null); + setCreating(false); + } + + async function save() { + if (!content.trim()) return; await runAction(async () => { - await createNote(newContent); - setNewContent(''); + if (creating) { + await createNote(content); + } else { + await updateNote(selected.id, content); + } + closePopup(); await reload(); }); } - function startEdit(note) { - setEditingId(note.id); - setEditingContent(note.content); - } - - function cancelEdit() { - setEditingId(null); - setEditingContent(''); - } - - async function saveEdit(id) { - if (!editingContent.trim()) return; - await runAction(async () => { - await updateNote(id, editingContent); - cancelEdit(); - await reload(); - }); - } - - function confirmDelete(note) { + function confirmDelete() { openDialog({ tone: 'warning', title: t('common_confirm_delete_title'), @@ -56,7 +71,8 @@ export default function NotasPage() { { label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { - await deleteNote(note.id); + await deleteNote(selected.id); + closePopup(); await reload(); pushToast({ tone: 'success', message: t('common_saved') }); }), @@ -65,61 +81,101 @@ export default function NotasPage() { }); } + const userOptions = (users || []) + .filter((u) => !shares.some((s) => s.user_id === u.id)) + .map((u) => ({ value: u.id, label: u.name })); + + async function addShare() { + if (!sharingWith) return; + await runAction(async () => { + setShares(await addNoteShare(selected.id, Number(sharingWith))); + setSharingWith(''); + }); + } + + function removeShare(userId) { + runAction(async () => { + setShares(await removeNoteShare(selected.id, userId)); + }); + } + if (!notes) return
{t('common_loading')}
; return (

{t('notes_title')}

-
-