diff --git a/API.md b/API.md
index 8db2ba4..f17844a 100644
--- a/API.md
+++ b/API.md
@@ -134,6 +134,15 @@
`backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica
ningún cambio, la actualización real sigue siendo manual.
+### Log de errores (`/api/error-logs`)
+- `POST /api/error-logs` — público (sin auth, rate-limited a 30/15min), lo usa el frontend para
+ reportar errores propios (incluso sin sesión). `{ message, stack?, url? }`. Si llega un token
+ válido en `Authorization`, se guarda el usuario asociado, sin exigirlo.
+- `GET /api/error-logs?source=backend|frontend&limit=` — solo Superadministrador. Lista los
+ errores más recientes (máx 1000 guardados, se recorta solo). Backend guarda `stack`/`url`/
+ `method`/usuario automáticamente en cada excepción 500 no manejada (ver `errorHandler.js`).
+- `DELETE /api/error-logs` — solo Superadministrador. Borra todo el historial.
+
### Dashboard (`/api/dashboard`)
- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio)
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md
index 345676c..d00d698 100644
--- a/BASE_DE_DATOS.md
+++ b/BASE_DE_DATOS.md
@@ -38,6 +38,7 @@
| `attachments` | archivos adjuntos a proyecto o actividad |
| `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 |
## Relaciones clave
@@ -102,6 +103,9 @@
además sea `project_lead` global, comportamiento previo sin cambios).
- Empresa asumida única (no hay tabla `companies` en el MVP).
- Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar.
+- `error_logs`: sin límite de tiempo, pero se recorta solo a los 1000 registros más recientes en
+ cada inserción (evita crecimiento sin control si algo entra en loop de errores). Se puede vaciar
+ del todo desde el panel ("Limpiar registro").
## Riesgos o decisiones
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53ed9fc..ee3164c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog - project.linkedcorp.net
+## [1.21.0] - 2026-09-17
+### Added
+- Log de errores para el administrador, panel nuevo en Configuración ("Errores del sistema",
+ solo superadmin): lista errores de backend (excepciones 500 no manejadas, con stack completo,
+ ruta, método y usuario si estaba logueado) y de frontend (errores JS y promesas sin `catch` en
+ el navegador del usuario, capturados automáticamente — antes solo aparecían en la consola del
+ navegador de esa persona, invisibles para cualquier otro). Filtro por origen y botón para
+ limpiar el historial.
+- Error Boundary de React: si algo rompe el render de la app, ya no queda una pantalla en blanco
+ — se muestra un aviso simple con botón para recargar, y el error queda reportado igual.
+- Nuevo `error_logs` (tabla + `GET/DELETE /api/error-logs`, solo superadmin) y
+ `POST /api/error-logs` (público, con rate-limit, para que el frontend pueda reportar incluso
+ sin sesión — ej. en la pantalla de login).
+
## [1.20.0] - 2026-09-17
### Added
- Convertir una actividad en proyecto (menú de tres puntos en el listado de Actividades). Como
diff --git a/VERSION b/VERSION
index 3989355..3500250 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.20.0
+1.21.0
diff --git a/backend/database/migrations/013_error_logs.sql b/backend/database/migrations/013_error_logs.sql
new file mode 100644
index 0000000..134996b
--- /dev/null
+++ b/backend/database/migrations/013_error_logs.sql
@@ -0,0 +1,15 @@
+-- Log de errores para el admin (panel en Configuracion): backend (excepciones 500 no manejadas)
+-- y frontend (errores JS/promesas no manejadas en el navegador, reportados via POST publico).
+CREATE TABLE IF NOT EXISTS error_logs (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ source ENUM('backend', 'frontend') NOT NULL,
+ message TEXT NOT NULL,
+ stack TEXT NULL,
+ url VARCHAR(500) NULL,
+ method VARCHAR(10) NULL,
+ user_id INT NULL,
+ user_agent VARCHAR(255) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_error_logs_created_at (created_at),
+ CONSTRAINT fk_error_logs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/backend/package.json b/backend/package.json
index 14af306..6454e17 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
- "version": "1.20.0",
+ "version": "1.21.0",
"private": true,
"type": "module",
"main": "src/server.js",
diff --git a/backend/src/app.js b/backend/src/app.js
index f7f3b17..b7fe516 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -21,6 +21,7 @@ import companyRoutes from './modules/company/routes.js';
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';
export const app = express();
@@ -46,6 +47,7 @@ app.use('/api/company', companyRoutes);
app.use('/api/recurring-tasks', recurringTasksRoutes);
app.use('/api/search', searchRoutes);
app.use('/api/updater', updaterRoutes);
+app.use('/api/error-logs', errorLogsRoutes);
// 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/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js
index 04937d5..44f3091 100644
--- a/backend/src/middleware/errorHandler.js
+++ b/backend/src/middleware/errorHandler.js
@@ -1,4 +1,5 @@
import { HttpError } from '../utils/http.js';
+import { logBackendError } from '../modules/errorLogs/service.js';
export function notFoundHandler(req, res) {
res.status(404).json({ success: false, data: null, error: 'Ruta no encontrada' });
@@ -9,6 +10,9 @@ export function errorHandler(err, req, res, next) { // eslint-disable-line no-un
return res.status(err.status).json({ success: false, data: null, error: err.message });
}
console.error('[error]', err);
+ // Fire-and-forget: logBackendError nunca tira (se traga sus propios fallos), no bloquea la
+ // respuesta al cliente.
+ logBackendError(err, req);
const isProd = process.env.NODE_ENV === 'production';
res.status(500).json({
success: false,
diff --git a/backend/src/modules/errorLogs/controller.js b/backend/src/modules/errorLogs/controller.js
new file mode 100644
index 0000000..b9b3257
--- /dev/null
+++ b/backend/src/modules/errorLogs/controller.js
@@ -0,0 +1,16 @@
+import { ok, asyncHandler } from '../../utils/http.js';
+import * as service from './service.js';
+
+export const getErrorLogs = asyncHandler(async (req, res) => {
+ ok(res, await service.listErrors({ source: req.query.source, limit: req.query.limit }));
+});
+
+export const postErrorLog = asyncHandler(async (req, res) => {
+ await service.logFrontendError(req.body, req);
+ ok(res, { logged: true }, 201);
+});
+
+export const deleteErrorLogs = asyncHandler(async (req, res) => {
+ await service.clearErrors();
+ ok(res, { cleared: true });
+});
diff --git a/backend/src/modules/errorLogs/repository.js b/backend/src/modules/errorLogs/repository.js
new file mode 100644
index 0000000..03fc2af
--- /dev/null
+++ b/backend/src/modules/errorLogs/repository.js
@@ -0,0 +1,36 @@
+import { pool } from '../../config/db.js';
+
+// Evita crecimiento sin limite (el frontend puede reportar seguido si algo entra en loop de
+// errores): al insertar, se recorta a los MAX_ROWS mas recientes.
+const MAX_ROWS = 1000;
+
+export async function create({ source, message, stack, url, method, userId, userAgent }) {
+ await pool.query(
+ `INSERT INTO error_logs (source, message, stack, url, method, user_id, user_agent)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [source, message, stack || null, url || null, method || null, userId || null, userAgent || null]
+ );
+ await pool.query(
+ `DELETE FROM error_logs WHERE id NOT IN (SELECT id FROM (SELECT id FROM error_logs ORDER BY created_at DESC LIMIT ?) x)`,
+ [MAX_ROWS]
+ );
+}
+
+export async function findAll({ source, limit = 200 } = {}) {
+ const clauses = [];
+ const params = [];
+ if (source) { clauses.push('el.source = ?'); params.push(source); }
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
+ const [rows] = await pool.query(
+ `SELECT el.id, el.source, el.message, el.stack, el.url, el.method,
+ el.user_id, u.name AS user_name, el.user_agent, el.created_at
+ FROM error_logs el LEFT JOIN users u ON u.id = el.user_id
+ ${where} ORDER BY el.created_at DESC LIMIT ?`,
+ [...params, Number(limit)]
+ );
+ return rows;
+}
+
+export async function clearAll() {
+ await pool.query('DELETE FROM error_logs');
+}
diff --git a/backend/src/modules/errorLogs/routes.js b/backend/src/modules/errorLogs/routes.js
new file mode 100644
index 0000000..30041b7
--- /dev/null
+++ b/backend/src/modules/errorLogs/routes.js
@@ -0,0 +1,16 @@
+import { Router } from 'express';
+import rateLimit from 'express-rate-limit';
+import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
+import { getErrorLogs, postErrorLog, deleteErrorLogs } from './controller.js';
+
+const router = Router();
+
+// Publico (sin requireAuth): el frontend debe poder reportar un error incluso en paginas sin
+// sesion (login, registro). Rate-limited para que no se pueda usar para floodear la tabla.
+const reportLimiter = rateLimit({ windowMs: 5 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
+router.post('/', reportLimiter, postErrorLog);
+
+router.get('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), getErrorLogs);
+router.delete('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), deleteErrorLogs);
+
+export default router;
diff --git a/backend/src/modules/errorLogs/service.js b/backend/src/modules/errorLogs/service.js
new file mode 100644
index 0000000..f2b7020
--- /dev/null
+++ b/backend/src/modules/errorLogs/service.js
@@ -0,0 +1,52 @@
+import jwt from 'jsonwebtoken';
+import * as repo from './repository.js';
+import { env } from '../../config/env.js';
+import { HttpError } from '../../utils/http.js';
+
+// Nunca debe tirar: si falla el logueo de un error, no puede convertirse en OTRO error sin
+// manejar que rompa la respuesta original. Se traga cualquier fallo propio (solo avisa por consola).
+export async function logBackendError(err, req) {
+ try {
+ await repo.create({
+ source: 'backend',
+ message: (err?.message || String(err)).slice(0, 2000),
+ stack: (err?.stack || '').slice(0, 5000) || null,
+ url: req?.originalUrl?.slice(0, 500) || null,
+ method: req?.method || null,
+ userId: req?.currentUser?.id || null,
+ userAgent: (req?.headers?.['user-agent'] || '').slice(0, 255) || null,
+ });
+ } catch (logErr) {
+ console.error('[error_logs] no se pudo guardar el error:', logErr.message);
+ }
+}
+
+// El reporte desde el navegador es publico (debe funcionar incluso en /login, sin sesion) — si
+// hay un token valido lo usamos solo para saber quien era, sin exigirlo.
+function decodeOptionalUserId(req) {
+ const header = req.headers.authorization || '';
+ const token = header.startsWith('Bearer ') ? header.slice(7) : null;
+ if (!token) return null;
+ try {
+ return jwt.verify(token, env.jwtSecret).sub || null;
+ } catch {
+ return null;
+ }
+}
+
+export async function logFrontendError(payload, req) {
+ const message = (payload.message || '').trim().slice(0, 2000);
+ if (!message) throw new HttpError(400, 'message es obligatorio');
+ await repo.create({
+ source: 'frontend',
+ message,
+ stack: (payload.stack || '').slice(0, 5000) || null,
+ url: (payload.url || '').slice(0, 500) || null,
+ method: null,
+ userId: decodeOptionalUserId(req),
+ userAgent: (req.headers['user-agent'] || '').slice(0, 255) || null,
+ });
+}
+
+export const listErrors = (filters) => repo.findAll(filters);
+export const clearErrors = () => repo.clearAll();
diff --git a/frontend/package.json b/frontend/package.json
index 32cc533..ef5d399 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
- "version": "1.20.0",
+ "version": "1.21.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx
index 8ddbaed..1806a9a 100644
--- a/frontend/src/app/App.jsx
+++ b/frontend/src/app/App.jsx
@@ -4,6 +4,7 @@ import { UiProvider } from '../context/ui-context.jsx';
import { BrandingProvider } from '../context/branding-context.jsx';
import { ContentDialogHost } from '../components/ContentDialog.jsx';
import { ToastHost } from '../components/ToastHost.jsx';
+import { ErrorBoundary } from '../components/ErrorBoundary.jsx';
import { ProtectedRoute } from './ProtectedRoute.jsx';
import AppShell from './AppShell.jsx';
import LoginPage from '../pages/Login/LoginPage.jsx';
@@ -21,31 +22,33 @@ import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
export default function App() {
return (
-
+ El error ya quedó registrado para el administrador. Probá recargar la página. +
+ +{log.stack}
+