From 59aadc6a35be3446bd76578ad365e29dd9672e5a Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 16 Sep 2026 17:11:58 -0500 Subject: [PATCH] feat: panel de Actualizaciones en Configuracion (solo informativo) Compara la version instalada contra el CHANGELOG.md de main en Gitea (lectura anonima por HTTPS, sin token) y muestra cada version pendiente con su criticidad -- derivada del digito que cambio (1ro=Alta, 2do=Media, 3ro=Baja, sin etiquetar nada a mano, regla ya documentada en VERSIONADO.md), una descripcion breve, y si trae migraciones de base de datos nuevas. Banner en rojo avisando que la actualizacion requiere reiniciar el sistema. No instala nada automaticamente -- solo informa. Aplicar la actualizacion (git pull, build, migrar, reiniciar servicios) sigue siendo manual, a criterio del administrador de cada instalacion, tal cual DEPLOY_WEB.md ya documenta. Nuevo GET /api/updater/status (solo superadmin). Requiere que el repo en Gitea sea publico con vista anonima habilitada -- documentado en DEPLOY_WEB.md. Co-Authored-By: Claude Sonnet 5 --- API.md | 10 ++ CHANGELOG.md | 14 +++ DEPLOY_WEB.md | 6 ++ VERSION | 2 +- backend/package.json | 2 +- backend/src/app.js | 2 + backend/src/modules/updater/controller.js | 6 ++ backend/src/modules/updater/repository.js | 26 +++++ backend/src/modules/updater/routes.js | 10 ++ backend/src/modules/updater/service.js | 102 ++++++++++++++++++ frontend/package.json | 2 +- frontend/src/i18n/messages.js | 24 +++++ .../pages/Configuracion/ConfiguracionPage.jsx | 7 +- .../src/pages/Configuracion/UpdaterPanel.jsx | 61 +++++++++++ frontend/src/services/updater.js | 3 + 15 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 backend/src/modules/updater/controller.js create mode 100644 backend/src/modules/updater/repository.js create mode 100644 backend/src/modules/updater/routes.js create mode 100644 backend/src/modules/updater/service.js create mode 100644 frontend/src/pages/Configuracion/UpdaterPanel.jsx create mode 100644 frontend/src/services/updater.js diff --git a/API.md b/API.md index a9dd1f4..3232e43 100644 --- a/API.md +++ b/API.md @@ -119,6 +119,16 @@ - `GET /api/search?q=texto&projectId=1` — acotado a un proyecto: solo `{ activities, tasks }` de ESE proyecto (sin el dato del proyecto, ya es el contexto). +### Actualizaciones (`/api/updater`) — solo Superadministrador +- `GET /api/updater/status` — compara la `VERSION` local contra `CHANGELOG.md` de `main` en + Gitea (leído de forma anónima por HTTPS, sin token — requiere repo público y vista anónima + habilitada en el servidor de Gitea). `{ currentVersion, upToDate, latestVersion, + overallCriticality?, hasMigrations, pending: [{ version, date, criticality, summary }] }`. + `criticality` se deriva del dígito de versión que cambió (X.Y.Z: 1ro=alta, 2do=media, + 3ro=baja), nunca se etiqueta a mano. `hasMigrations` compara los archivos de + `backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica + ningún cambio, la actualización real sigue siendo manual. + ### 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/CHANGELOG.md b/CHANGELOG.md index c174391..aceb4b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog - project.linkedcorp.net +## [1.19.0] - 2026-09-16 +### Added +- Panel "Actualizaciones" en Configuración (solo superadmin): compara la versión instalada contra + el `CHANGELOG.md` de `main` en Gitea y muestra cada versión pendiente con su criticidad (deriva + del dígito que cambió — 1ro=Alta, 2do=Media, 3ro=Baja, sin etiquetar nada a mano), una + descripción breve, y si trae cambios de base de datos (comparando los archivos de + `backend/database/migrations/` locales contra los de `main`). Banner en rojo, siempre visible + si hay algo pendiente, avisando que la actualización requiere reiniciar el sistema. No instala + nada automáticamente — solo informa; aplicar la actualización sigue siendo manual (SSH), a + criterio del administrador de cada instalación. +- Nuevo endpoint `GET /api/updater/status` (solo superadmin). Lee el repo de Gitea de forma + anónima por HTTPS — no requiere ningún token ni credencial en el server instalado, solo que el + repo sea público y el servidor de Gitea permita vista anónima. + ## [1.18.0] - 2026-09-16 ### Added - Buscador de Proyectos ahora también busca en actividades y tareas de cualquier proyecto, no diff --git a/DEPLOY_WEB.md b/DEPLOY_WEB.md index b94b969..7ff6dee 100644 --- a/DEPLOY_WEB.md +++ b/DEPLOY_WEB.md @@ -26,6 +26,12 @@ hace falta `create-admin.js` en instalaciones nuevas. Las secciones de abajo son la referencia manual (lo que hace el script paso a paso) — útiles para entender el porqué de cada cosa o para resolver un problema puntual sin volver a correr todo. +**Requisito para el panel "Actualizaciones"** (Configuración → superadmin): el servidor instalado +necesita poder llegar por HTTPS a `gitea.linkedcorp.net` de forma anónima (repo público + vista +anónima habilitada en Gitea) para poder consultar el `CHANGELOG.md` y las migraciones nuevas. No +requiere ningún token ni credencial. Si no hay conectividad a Gitea, ese panel simplemente muestra +el error al buscar actualizaciones — el resto de la app funciona igual. + --- ## Primer despliegue en producción (ya ejecutado — referencia) diff --git a/VERSION b/VERSION index 84cc529..815d5ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.18.0 +1.19.0 diff --git a/backend/package.json b/backend/package.json index 3443bd1..b20a7b5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.18.0", + "version": "1.19.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index c24e084..f7f3b17 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -20,6 +20,7 @@ import settingsRoutes from './modules/settings/routes.js'; 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'; export const app = express(); @@ -44,6 +45,7 @@ app.use('/api/settings', settingsRoutes); app.use('/api/company', companyRoutes); app.use('/api/recurring-tasks', recurringTasksRoutes); app.use('/api/search', searchRoutes); +app.use('/api/updater', updaterRoutes); // 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/updater/controller.js b/backend/src/modules/updater/controller.js new file mode 100644 index 0000000..9cbbf75 --- /dev/null +++ b/backend/src/modules/updater/controller.js @@ -0,0 +1,6 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getUpdateStatus = asyncHandler(async (req, res) => { + ok(res, await service.checkForUpdates()); +}); diff --git a/backend/src/modules/updater/repository.js b/backend/src/modules/updater/repository.js new file mode 100644 index 0000000..20e0c39 --- /dev/null +++ b/backend/src/modules/updater/repository.js @@ -0,0 +1,26 @@ +// Lee el repo en Gitea de forma anonima (repo publico + vista anonima habilitada en el servidor +// de Gitea) — el servidor de un cliente instalado en otro lado NO necesita ningun token ni +// credencial para esto, solo poder llegar por HTTPS a Gitea. +const GITEA_BASE = process.env.GITEA_BASE_URL || 'http://gitea.linkedcorp.net'; +const GITEA_OWNER = process.env.GITEA_OWNER || 'mgonzalez'; +const GITEA_REPO = process.env.GITEA_REPO || 'project.linkedcorp.net'; +const BRANCH = 'main'; + +async function getRaw(path) { + const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/raw/${path}?ref=${BRANCH}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Gitea respondio ${res.status} al leer ${path}`); + return res.text(); +} + +export async function fetchChangelog() { + return getRaw('CHANGELOG.md'); +} + +export async function fetchRemoteMigrationNames() { + const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/contents/backend/database/migrations?ref=${BRANCH}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Gitea respondio ${res.status} al listar migraciones`); + const entries = await res.json(); + return entries.filter((e) => e.type === 'file').map((e) => e.name); +} diff --git a/backend/src/modules/updater/routes.js b/backend/src/modules/updater/routes.js new file mode 100644 index 0000000..af959b2 --- /dev/null +++ b/backend/src/modules/updater/routes.js @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js'; +import { getUpdateStatus } from './controller.js'; + +const router = Router(); + +router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS)); +router.get('/status', getUpdateStatus); + +export default router; diff --git a/backend/src/modules/updater/service.js b/backend/src/modules/updater/service.js new file mode 100644 index 0000000..d769794 --- /dev/null +++ b/backend/src/modules/updater/service.js @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as repo from './repository.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION'); +const MIGRATIONS_DIR = path.resolve(__dirname, '../../../database/migrations'); + +function parseVersion(v) { + const [major, minor, patch] = v.trim().split('.').map(Number); + return { major, minor, patch }; +} + +function compareVersions(a, b) { + const va = parseVersion(a); + const vb = parseVersion(b); + if (va.major !== vb.major) return va.major - vb.major; + if (va.minor !== vb.minor) return va.minor - vb.minor; + return va.patch - vb.patch; +} + +// Regla del proyecto (VERSIONADO.md): cambia el 1er digito = alta, 2do = media, 3er = baja. +// Sin etiquetar nada a mano — se deriva solo de que numero cambio entre las dos versiones. +function classifyBump(fromVersion, toVersion) { + const a = parseVersion(fromVersion); + const b = parseVersion(toVersion); + if (b.major !== a.major) return 'alta'; + if (b.minor !== a.minor) return 'media'; + return 'baja'; +} + +// Se parte el texto en la propia delimitacion "## [" (en vez de una sola regex con lookahead de +// fin-de-texto): con la bandera `m`, `$` matchea fin de CUALQUIER linea, asi que un lookahead +// `\s*$` para "fin del changelog completo" en realidad cortaba en la primera linea en blanco +// (el cuerpo quedaba vacio siempre). Partiendo por bloque no hay esa ambiguedad. +function parseChangelog(text) { + const entries = []; + const blocks = text.split(/\n(?=## \[)/); + for (const block of blocks) { + const match = block.match(/^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*\n([\s\S]*)$/); + if (match) entries.push({ version: match[1], date: match[2], body: match[3].trim() }); + } + return entries; +} + +// Resumen corto: saca los encabezados "### Algo" y las vinietas "- ", deja solo el texto, truncado. +function summarize(body, maxLength = 260) { + const lines = body + .split('\n') + .map((l) => l.trim().replace(/^-\s+/, '')) + .filter((l) => l && !l.startsWith('###')); + const joined = lines.join(' ').replace(/\s+/g, ' ').trim(); + return joined.length > maxLength ? `${joined.slice(0, maxLength).trim()}…` : joined; +} + +function getLocalVersion() { + return fs.readFileSync(VERSION_FILE, 'utf8').trim(); +} + +function getLocalMigrationNames() { + return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')); +} + +export async function checkForUpdates() { + const currentVersion = getLocalVersion(); + const changelogText = await repo.fetchChangelog(); + const allEntries = parseChangelog(changelogText); + + const pending = allEntries + .filter((e) => compareVersions(e.version, currentVersion) > 0) + .sort((a, b) => compareVersions(a.version, b.version)); + + if (pending.length === 0) { + return { currentVersion, upToDate: true, latestVersion: currentVersion, pending: [], hasMigrations: false }; + } + + let previousInChain = currentVersion; + const pendingWithMeta = pending.map((e) => { + const criticality = classifyBump(previousInChain, e.version); + previousInChain = e.version; + return { version: e.version, date: e.date, criticality, summary: summarize(e.body) }; + }); + + const localMigrations = new Set(getLocalMigrationNames()); + const remoteMigrations = await repo.fetchRemoteMigrationNames(); + const hasMigrations = remoteMigrations.some((name) => !localMigrations.has(name)); + + const latestVersion = pending[pending.length - 1].version; + const overallCriticality = pendingWithMeta.some((e) => e.criticality === 'alta') + ? 'alta' + : pendingWithMeta.some((e) => e.criticality === 'media') ? 'media' : 'baja'; + + return { + currentVersion, + upToDate: false, + latestVersion, + overallCriticality, + hasMigrations, + pending: pendingWithMeta, + }; +} diff --git a/frontend/package.json b/frontend/package.json index c42377b..51339c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.18.0", + "version": "1.19.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 83f803b..ce633dc 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -70,6 +70,18 @@ export const messages = { smtp_test_send: 'Enviar prueba', smtp_test_sending: 'Enviando...', smtp_test_success: 'Correo de prueba enviado correctamente a', + + config_updater: 'Actualizaciones', + updater_check: 'Buscar actualizaciones', + updater_current_version: 'Versión instalada:', + updater_up_to_date: 'Estás en la última versión disponible.', + updater_available: 'Actualización disponible', + updater_restart_required: 'Esta actualización requiere reiniciar el sistema — coordina con tu administrador antes de aplicarla.', + updater_has_migrations: 'Incluye cambios en la base de datos (migraciones nuevas).', + updater_criticality_alta: 'Alta', + updater_criticality_media: 'Media', + updater_criticality_baja: 'Baja', + updater_rollback_hint: 'Antes de actualizar, respalda la base de datos. Si algo sale mal, restaura ese respaldo y volvé al commit anterior a esta actualización en el historial de Gitea.', password_show: 'Mostrar clave', password_hide: 'Ocultar clave', common_new: 'Nuevo', @@ -318,6 +330,18 @@ export const messages = { smtp_test_send: 'Send test', smtp_test_sending: 'Sending...', smtp_test_success: 'Test email sent successfully to', + + config_updater: 'Updates', + updater_check: 'Check for updates', + updater_current_version: 'Installed version:', + updater_up_to_date: 'You are on the latest available version.', + updater_available: 'Update available', + updater_restart_required: 'This update requires restarting the system — coordinate with your administrator before applying it.', + updater_has_migrations: 'Includes database changes (new migrations).', + updater_criticality_alta: 'High', + updater_criticality_media: 'Medium', + updater_criticality_baja: 'Low', + updater_rollback_hint: 'Back up the database before updating. If something goes wrong, restore that backup and roll back to the commit before this update in the Gitea history.', password_show: 'Show password', password_hide: 'Hide password', common_new: 'New', diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx index dbed569..b966f59 100644 --- a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -1,5 +1,5 @@ import { Navigate } from 'react-router-dom'; -import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2 } from 'lucide-react'; +import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { Expander } from '../../components/Expander.jsx'; @@ -8,6 +8,7 @@ import { ComboBox } from '../../components/ComboBox.jsx'; import { CatalogEditor } from './CatalogEditor.jsx'; import SmtpSettingsPanel from './SmtpSettingsPanel.jsx'; import CompanySettingsPanel from './CompanySettingsPanel.jsx'; +import UpdaterPanel from './UpdaterPanel.jsx'; export default function ConfiguracionPage() { const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi(); @@ -101,6 +102,10 @@ export default function ConfiguracionPage() { + + + + ); } diff --git a/frontend/src/pages/Configuracion/UpdaterPanel.jsx b/frontend/src/pages/Configuracion/UpdaterPanel.jsx new file mode 100644 index 0000000..4a5dc10 --- /dev/null +++ b/frontend/src/pages/Configuracion/UpdaterPanel.jsx @@ -0,0 +1,61 @@ +import { RefreshCw } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync } from '../../hooks/useAsync.js'; +import { getUpdateStatus } from '../../services/updater.js'; +import { Button } from '../../components/Button.jsx'; +import { Badge } from '../../components/Badge.jsx'; +import { InfoBar } from '../../components/InfoBar.jsx'; + +const CRITICALITY_COLOR = { alta: 'danger', media: 'warning', baja: 'success' }; + +export default function UpdaterPanel() { + const { t } = useUi(); + const { data: status, loading, error, reload } = useAsync(getUpdateStatus, []); + + return ( +
+
+ + {status && {t('updater_current_version')} v{status.currentVersion}} +
+ + {loading &&
{t('common_loading')}
} + {error && {error}} + + {status && status.upToDate && ( + {t('updater_up_to_date')} + )} + + {status && !status.upToDate && ( +
+ {t('updater_restart_required')} + {status.hasMigrations && ( + {t('updater_has_migrations')} + )} + +
+

+ {t('updater_available')} — v{status.currentVersion} → v{status.latestVersion} + {' '} + {t(`updater_criticality_${status.overallCriticality}`)} +

+ {status.pending.map((entry) => ( +
+
+ v{entry.version} + {entry.date} + {t(`updater_criticality_${entry.criticality}`)} +
+
{entry.summary}
+
+ ))} +
+ +
{t('updater_rollback_hint')}
+
+ )} +
+ ); +} diff --git a/frontend/src/services/updater.js b/frontend/src/services/updater.js new file mode 100644 index 0000000..31d0f29 --- /dev/null +++ b/frontend/src/services/updater.js @@ -0,0 +1,3 @@ +import { api } from './api.js'; + +export const getUpdateStatus = () => api.get('/updater/status');