From ceda779b75962afb58d2a45df7f8fd2ec5a0ec73 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 17 Sep 2026 15:54:05 -0500 Subject: [PATCH] feat: menu Notas -- apuntes rapidos personales Nuevo menu "Notas" en el sidebar: crear, editar y eliminar apuntes rapidos, con fecha y hora de creacion (y de ultima edicion si aplica). Siempre privadas de cada usuario -- una nota de otro usuario responde 404, no 403, para no revelar que existe. Sin restriccion de rol. Nuevo notes (tabla) + GET/POST/PUT/DELETE /api/notes. Probado de punta a punta en dev: crear, editar, borrar y el formato de fecha/hora funcionan correctamente. Co-Authored-By: Claude Sonnet 5 --- API.md | 6 + BASE_DE_DATOS.md | 1 + CHANGELOG.md | 7 ++ VERSION | 2 +- backend/database/migrations/015_notes.sql | 11 ++ backend/package.json | 2 +- backend/src/app.js | 2 + backend/src/modules/notes/controller.js | 19 ++++ backend/src/modules/notes/repository.js | 28 +++++ backend/src/modules/notes/routes.js | 14 +++ backend/src/modules/notes/service.js | 30 +++++ frontend/package.json | 2 +- frontend/src/app/App.jsx | 2 + frontend/src/components/NavigationView.jsx | 3 +- frontend/src/i18n/messages.js | 12 ++ frontend/src/pages/Notas/NotasPage.jsx | 125 +++++++++++++++++++++ frontend/src/services/notes.js | 6 + 17 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 backend/database/migrations/015_notes.sql create mode 100644 backend/src/modules/notes/controller.js create mode 100644 backend/src/modules/notes/repository.js create mode 100644 backend/src/modules/notes/routes.js create mode 100644 backend/src/modules/notes/service.js create mode 100644 frontend/src/pages/Notas/NotasPage.jsx create mode 100644 frontend/src/services/notes.js diff --git a/API.md b/API.md index 6da93e3..1e0d1cc 100644 --- a/API.md +++ b/API.md @@ -113,6 +113,12 @@ - `GET /api/attachments?entityType=activity&entityId=1` · `POST /api/attachments` (multipart) - `GET /api/attachments/:id/download` · `DELETE /api/attachments/:id` +### Notas (`/api/notes`) +- `GET /api/notes` — solo las del usuario autenticado, ordenadas por `updated_at` descendente. +- `POST /api/notes` — `{ content }`. `PUT /api/notes/:id` — `{ content }`. `DELETE /api/notes/:id`. +- Siempre privadas: una nota de otro usuario responde `404` (no `403`, para no revelar que + existe). Sin restricción de rol — cualquier autenticado tiene las suyas. + ### Notificaciones (`/api/notifications`) - `GET /api/notifications` · `PATCH /api/notifications/:id/read` · `PATCH /api/notifications/read-all` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 302f9f8..5a6f393 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -39,6 +39,7 @@ | `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" | ## Relaciones clave diff --git a/CHANGELOG.md b/CHANGELOG.md index d99000c..a8cdd5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog - project.linkedcorp.net +## [1.23.0] - 2026-09-17 +### Added +- Menú "Notas" nuevo: apuntes rápidos y simples, siempre privados de cada usuario (nadie más los + ve). Crear, editar y eliminar, con fecha y hora de creación (y de última edición, si aplica). + Sin permisos especiales — cualquier usuario autenticado tiene los suyos. +- Nuevo `notes` (tabla) + `GET/POST/PUT/DELETE /api/notes`. + ## [1.22.0] - 2026-09-17 ### Added - La app ahora es instalable desde el navegador (PWA): ícono propio, se abre en su propia ventana diff --git a/VERSION b/VERSION index 57807d6..a6c2798 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.22.0 +1.23.0 diff --git a/backend/database/migrations/015_notes.sql b/backend/database/migrations/015_notes.sql new file mode 100644 index 0000000..325bb75 --- /dev/null +++ b/backend/database/migrations/015_notes.sql @@ -0,0 +1,11 @@ +-- Apuntes rapidos personales (menu "Notas"): siempre privados de cada usuario, sin permisos +-- especiales de rol -- cualquiera autenticado ve/edita/borra solo los suyos. +CREATE TABLE IF NOT EXISTS notes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_notes_user (user_id), + CONSTRAINT fk_notes_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 7b462ae..fae0fb7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.22.0", + "version": "1.23.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index b7fe516..a1fee32 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -22,6 +22,7 @@ import recurringTasksRoutes from './modules/recurring-tasks/routes.js'; import searchRoutes from './modules/search/routes.js'; import updaterRoutes from './modules/updater/routes.js'; import errorLogsRoutes from './modules/errorLogs/routes.js'; +import notesRoutes from './modules/notes/routes.js'; export const app = express(); @@ -48,6 +49,7 @@ app.use('/api/recurring-tasks', recurringTasksRoutes); app.use('/api/search', searchRoutes); app.use('/api/updater', updaterRoutes); app.use('/api/error-logs', errorLogsRoutes); +app.use('/api/notes', notesRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/modules/notes/controller.js b/backend/src/modules/notes/controller.js new file mode 100644 index 0000000..8bf6edf --- /dev/null +++ b/backend/src/modules/notes/controller.js @@ -0,0 +1,19 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getNotes = asyncHandler(async (req, res) => { + ok(res, await service.listNotes(req.currentUser)); +}); + +export const postNote = asyncHandler(async (req, res) => { + ok(res, await service.createNote(req.body.content, req.currentUser), 201); +}); + +export const putNote = asyncHandler(async (req, res) => { + ok(res, await service.updateNote(Number(req.params.id), req.body.content, req.currentUser)); +}); + +export const deleteNote = asyncHandler(async (req, res) => { + await service.removeNote(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/notes/repository.js b/backend/src/modules/notes/repository.js new file mode 100644 index 0000000..5eb8b30 --- /dev/null +++ b/backend/src/modules/notes/repository.js @@ -0,0 +1,28 @@ +import { pool } from '../../config/db.js'; + +export async function findByUser(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] + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query('SELECT id, user_id, content, created_at, updated_at FROM notes WHERE id = ?', [id]); + return rows[0] || null; +} + +export async function create(userId, content) { + const [result] = await pool.query('INSERT INTO notes (user_id, content) VALUES (?, ?)', [userId, content]); + return findById(result.insertId); +} + +export async function update(id, content) { + await pool.query('UPDATE notes SET content = ? WHERE id = ?', [content, id]); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM notes WHERE id = ?', [id]); +} diff --git a/backend/src/modules/notes/routes.js b/backend/src/modules/notes/routes.js new file mode 100644 index 0000000..0ba398f --- /dev/null +++ b/backend/src/modules/notes/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getNotes, postNote, putNote, deleteNote } from './controller.js'; + +const router = Router(); + +// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas. +router.use(requireAuth); +router.get('/', getNotes); +router.post('/', postNote); +router.put('/:id', putNote); +router.delete('/:id', deleteNote); + +export default router; diff --git a/backend/src/modules/notes/service.js b/backend/src/modules/notes/service.js new file mode 100644 index 0000000..b2f8eb4 --- /dev/null +++ b/backend/src/modules/notes/service.js @@ -0,0 +1,30 @@ +import * as repo from './repository.js'; +import { HttpError } from '../../utils/http.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. +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 async function createNote(content, currentUser) { + const trimmed = (content || '').trim(); + if (!trimmed) throw new HttpError(400, 'El contenido es obligatorio'); + return repo.create(currentUser.id, trimmed); +} + +export async function updateNote(id, content, currentUser) { + await getOwnNoteOr404(id, currentUser); + const trimmed = (content || '').trim(); + if (!trimmed) throw new HttpError(400, 'El contenido es obligatorio'); + return repo.update(id, trimmed); +} + +export async function removeNote(id, currentUser) { + await getOwnNoteOr404(id, currentUser); + await repo.remove(id); +} diff --git a/frontend/package.json b/frontend/package.json index d233695..5d2e762 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.22.0", + "version": "1.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 1806a9a..8c13615 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -19,6 +19,7 @@ import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx'; import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx'; import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; +import NotasPage from '../pages/Notas/NotasPage.jsx'; export default function App() { return ( @@ -39,6 +40,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index 5eca37c..6c26435 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat } from 'lucide-react'; +import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; import { useBranding } from '../context/branding-context.jsx'; @@ -12,6 +12,7 @@ const ITEMS = [ { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, { to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' }, + { to: '/notas', icon: StickyNote, labelKey: 'nav_notes' }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, ]; diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 6d67111..fc0982a 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -172,6 +172,12 @@ export const messages = { project_filter_due_soon: 'Solo próximos a vencer (7 días)', nav_recurring_tasks: 'Tareas recurrentes', + nav_notes: 'Notas', + notes_title: 'Notas', + notes_placeholder: 'Escribe un apunte rápido...', + notes_add: 'Agregar', + notes_empty: 'No tienes notas todavía.', + notes_updated: 'Editado', recurring_task_new: 'Nueva tarea recurrente', recurring_task_project: 'Proyecto de origen', recurring_task_every: 'Cada', @@ -448,6 +454,12 @@ export const messages = { project_filter_due_soon: 'Due soon only (7 days)', nav_recurring_tasks: 'Recurring tasks', + nav_notes: 'Notes', + notes_title: 'Notes', + notes_placeholder: 'Write a quick note...', + notes_add: 'Add', + notes_empty: 'You don\'t have any notes yet.', + notes_updated: 'Edited', 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 new file mode 100644 index 0000000..556f4a6 --- /dev/null +++ b/frontend/src/pages/Notas/NotasPage.jsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { Plus, Pencil, Save, X, Trash2 } 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 { Button } from '../../components/Button.jsx'; + +function formatDateTime(value) { + return String(value).slice(0, 16).replace('T', ' '); +} + +export default function NotasPage() { + const { t, pushToast, openDialog } = useUi(); + const runAction = useAction(); + const { data: notes, reload } = useAsync(listNotes, []); + + const [newContent, setNewContent] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + + async function addNote() { + if (!newContent.trim()) return; + await runAction(async () => { + await createNote(newContent); + setNewContent(''); + 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) { + 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 deleteNote(note.id); + await reload(); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + + if (!notes) return
{t('common_loading')}
; + + return ( +
+

{t('notes_title')}

+ +
+