commit 1d22adc42ea698b0f51bb260fffe5391ce4b2c2b Author: Milton Gonzalez Date: Thu Sep 3 08:37:58 2026 -0500 chore: estructura inicial del MVP Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias, dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc159 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# --- Secrets & env --- +.env +.env.* +!.env.example +backend/.env +backend/.env.* +!backend/.env.example +frontend/.env +frontend/.env.* +!frontend/.env.example + +# --- Dependencies --- +node_modules/ +backend/node_modules/ +frontend/node_modules/ + +# --- Build outputs --- +dist/ +build/ +coverage/ +frontend/dist/ +backend/dist/ + +# --- Logs --- +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +backend/logs/ + +# --- Persistent app data --- +storage/ +backend/storage/ +backend/uploads/ +backend/tmp/ +backend/temp/ + +# --- Editor / OS --- +.DS_Store +Thumbs.db +.token_gitea +.idea/ +.codex +.claude/ +.tmp-venv/ +.vscode/* +!.vscode/extensions.json +*.swp +*.swo + +# --- Local artifacts / references (NUNCA versionar) --- +Screenshot_*.png +test.pdf +LOCAL_DEPLOY_CONTEXT.md +SERVIDORES.md +PORTS_INVENTORY.md + +# --- Certificados / llaves --- +backend/*.p12 +backend/*.pfx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..55d6bdb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,157 @@ +# AGENTS.md - project.linkedcorp.net + +> **⚠️ ANTES DE ACTUAR, lee el orquestador del grupo:** `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt` +> Reglas globales (infra, secretos, UI/UX, Android, Git, versionado). Este archivo es solo el contexto especifico del proyecto. + +## ⛔ AISLAMIENTO: SOLO ESTE PROYECTO + +Regla dura del grupo (detalle en el orquestador, seccion "REGLA DE AISLAMIENTO ENTRE PROYECTOS"): + +- Se trabaja SOLO sobre este proyecto. Los otros proyectos del grupo NO se tocan NI se leen. +- Leer otro proyecto: unicamente si el usuario lo pide TEXTUALMENTE en ese pedido. +- Modificar otro proyecto: NUNCA, ni aunque el usuario lo nombre. +- Recursos compartidos (puertos, NPM, systemd, BD, DNS): verificar en la fuente real que esten + libres antes de ocuparlos; solo crear objetos nuevos de ESTE proyecto. + +## Proposito + +Contexto especifico del proyecto para el asistente de IA. No repite las reglas globales del grupo, +que estan en el orquestador `leeme_primero.txt`. + +## Identidad del proyecto + +- Nombre: `project.linkedcorp.net` +- Tipo: fullstack (gestión y seguimiento de proyectos/actividades TI) +- Estado: development +- Ruta raiz: `/home/mgonzalez/desarrollo/project.linkedcorp.net` + +## Stack real del proyecto + +### Frontend +- React + Vite +- UI: CSS propio por tokens (sin Tailwind) siguiendo la identidad `docs/UI-UX-WINDOWS.md` (Fluent/Windows 11) +- Iconos: `lucide-react` + +### Backend +- Node.js + Express +- API base: `/api` + +### Base de datos +- Motor: MariaDB +- Base principal: `project_linkedcorp` +- Host dev: `192.168.222.20` / Host prod: `192.168.222.32` (NO mezclar) +- La base ya existe: sí (creada en dev el 2026-09-03 con `crear-db-dev.sh`) + +## Arquetipo de despliegue + +**B — Dos servicios separados** (frontend y backend como procesos independientes). +- systemd: `project-linkedcorp-frontend` y `project-linkedcorp-backend` (a crear en el primer deploy). +- NPM: Proxy Host al frontend + Custom Location `/api` al backend. +- Puertos (registrados en `PORTS_INVENTORY.md` del grupo): frontend `5196`, backend `4019`. + +## Identidad UI/UX — DECISIÓN DE ESTE PROYECTO (no es la identidad estándar del grupo) + +Este proyecto usa `UI-UX-WINDOWS.md` / `UI-UX-WINDOWS.html` (Fluent Design / Windows 11: +NavigationView, clic derecho, Master-Detail, ContentDialog/InfoBar/Flyout/Toast) en vez de +`UI-UX-LINKEDCORP.md` (identidad web estándar del grupo). Fue decisión explícita del usuario al +arrancar el proyecto. Copia ajustada en `docs/UI-UX-WINDOWS.md`. + +Adaptación web (no es una app empaquetada, corre en pestaña de navegador): +- SÍ: tokens de color, tipografía, NavigationView, ListView+clic derecho, Master-Detail, + ContentDialog, InfoBar, Toast, Flyout, ComboBox/DatePicker propios, Expander, Treeview. +- NO: barra de título falsa con minimizar/maximizar/cerrar (no hay ventana de SO que controlar). +- Igual obligatorio: idioma es-EC/en-US + tema claro/oscuro + apariencia de botones, todo persistido. +- Set de íconos: Lucide (única opción usada en este proyecto). + +## Estructura acordada + +- `frontend/` +- `backend/` +- `docs/` + +## Modulos principales (MVP — ver SPEC.md para el detalle y lo diferido a fase 2) + +- Seguridad (usuarios, roles fijos, sesión, auditoría) +- Organización (departamentos con sub-áreas, usuarios) +- Proyectos (con miembros) +- Actividades + Subtareas + dependencias simples +- Dashboard (KPIs) +- Calendario y Gantt de solo lectura +- Comentarios y adjuntos +- Notificaciones in-app +- Configuración (catálogos: estados, prioridades, tipos de proyecto) + +## Restricciones tecnicas + +- No romper estructura existente. +- Mantener frontend y backend separados (arquetipo B). +- Variables de entorno, nunca secretos en código. +- Empresa asumida como única (single-company): no hay tabla `companies` en el MVP. +- Permisos por rol codificados en backend (`backend/src/middleware/auth.js`), no configurables por + UI en el MVP — ver tabla de roles en SPEC.md. + +## Arquitectura esperada + +### Backend +- `routes -> controller -> service -> repository -> db` +- Un directorio por módulo en `backend/src/modules//` + +### Frontend +- `src/components` (UI base Windows: NavigationView, ContentDialog, InfoBar, Toast, Flyout, ListView, ComboBox, DatePicker, Treeview, Field, Button) +- `src/pages` (Login, Dashboard, Organizacion, Proyectos, Configuracion) +- `src/services` (cliente API por módulo) +- `src/context` (auth, ui: tema/idioma/dialog/toast) +- `src/i18n` + +## Seguridad (proyecto con auth/usuarios) + +- Permisos validados SIEMPRE en el backend, nunca solo en el frontend. +- Sin autosuscripción: el administrador crea los usuarios. +- `401` → el frontend limpia sesión, redirige a login y muestra aviso (ContentDialog). +- Cierre de sesión por expiración de JWT. +- Auditoría en BD (`audit_log`): usuario, acción, entidad, fecha/hora, detalle. +- Credenciales/secretos solo en `.env` (nunca en código); `.env.example` sí se versiona. +- Cifrado: bcrypt para contraseñas. +- Revalidar `is_active` del usuario en cada request (un JWT firmado no basta). +- SQL siempre parametrizado. Uploads: validar MIME/tamaño, ruta de archivo generada por el servidor. + +## Regla de UX para pantallas + +- No mezclar listado y formulario de creación en la vista inicial de un módulo. +- Primero el listado; botón "Nuevo" abre creación. +- Detalle abre en modo lectura; "Editar" habilita campos; "Cancelar" vuelve a lectura sin guardar; + guardar solo si hubo cambios reales. +- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos) + van en pestañas reales, nunca apiladas ni navegando a otra pantalla. + +## Flujos criticos + +1. Login → Dashboard con KPIs del usuario. +2. Crear proyecto → asignar miembros → crear actividades → crear subtareas → avance se recalcula solo. +3. Actividad vencida o por vencer → notificación in-app al responsable. + +## Entorno operativo + +- Desarrollo: `npm run dev` en `backend/` (puerto 4019) y `frontend/` (puerto 5196, proxy `/api`). +- Producción: pendiente (deploy explícito posterior, ver PRODUCCION.md cuando se cree). +- Docker por defecto: no. + +## Regla de versionado + +- Versionado `X.Y.Z` (ver VERSIONADO.md). +- Antes de producción, validar si corresponde subir versión. +- Toda versión nueva se refleja en `VERSION` y `CHANGELOG.md`. + +## Documentacion relacionada + +- `README.md`, `SPEC.md`, `BASE_DE_DATOS.md`, `CONFIGURACION.md`, `API.md`, `VERSIONADO.md` +- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md` +- `ESTADO.md` (handoff — leer siempre al retomar el proyecto) + +## Pendientes para quedar listo + +- Fase 2: riesgos/incidencias como módulos propios, solicitudes formales, recursos/proveedores, + presupuesto y costos, registro de horas detallado, reportes exportables (PDF/Excel/CSV), + permisos configurables por módulo×acción, notificaciones por email. +- Deploy a producción (servidor .3, BD .32, systemd, NPM) — confirmar con el usuario cuando el MVP + esté validado en local. diff --git a/API.md b/API.md new file mode 100644 index 0000000..fb544e8 --- /dev/null +++ b/API.md @@ -0,0 +1,79 @@ +# API - project.linkedcorp.net + +## Base URL + +- Desarrollo: `http://localhost:4019/api` +- Producción: `https://project.linkedcorp.net/api` (pendiente de deploy) + +## Autenticacion + +- Tipo: Bearer JWT (`Authorization: Bearer `), emitido por `/api/auth/login`. +- El backend revalida `is_active` del usuario en cada request (no solo la firma del JWT). + +## Formato de respuesta + +```json +{ + "success": true, + "data": {}, + "error": null +} +``` + +## Endpoints principales + +### Auth +- `POST /api/auth/login` — `{ email, password }` → `{ token, user }` +- `POST /api/auth/logout` +- `GET /api/auth/me` — usuario actual + +### Usuarios (`/api/users`) — solo Administrador +- `GET /api/users` · `POST /api/users` · `GET /api/users/:id` · `PUT /api/users/:id` · `PATCH /api/users/:id/active` + +### Departamentos (`/api/departments`) +- `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id` + +### Proyectos (`/api/projects`) +- `GET /api/projects` (filtros: estado, responsable, prioridad) · `POST /api/projects` +- `GET /api/projects/:id` · `PUT /api/projects/:id` +- `POST /api/projects/:id/members` · `DELETE /api/projects/:id/members/:userId` + +### Actividades (`/api/activities`) +- `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities` +- `GET /api/activities/:id` · `PUT /api/activities/:id` +- `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId` + +### Subtareas (`/api/activities/:activityId/subtasks`) +- `GET` · `POST` · `PUT /api/subtasks/:id` · `PATCH /api/subtasks/:id/done` · `DELETE /api/subtasks/:id` + +### Comentarios (`/api/comments`) +- `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments` + +### Adjuntos (`/api/attachments`) +- `GET /api/attachments?entityType=activity&entityId=1` · `POST /api/attachments` (multipart) +- `GET /api/attachments/:id/download` · `DELETE /api/attachments/:id` + +### Notificaciones (`/api/notifications`) +- `GET /api/notifications` · `PATCH /api/notifications/:id/read` · `PATCH /api/notifications/read-all` + +### 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 +- `GET /api/projects/:id/gantt` — actividades con fechas/avance para el Gantt + +### Catálogos (`/api/catalogs`) — lectura general, escritura solo Administrador +- `GET/POST/PUT/DELETE /api/catalogs/project-statuses` +- `GET/POST/PUT/DELETE /api/catalogs/activity-statuses` +- `GET/POST/PUT/DELETE /api/catalogs/priorities` +- `GET/POST/PUT/DELETE /api/catalogs/project-types` + +### Auditoría (`/api/audit`) — solo Administrador / Jefe de TI +- `GET /api/audit?entityType=project&entityId=1` + +## Errores esperados + +- `400` validación +- `401` no autenticado / token inválido o expirado +- `403` sin permisos para la acción (según rol) +- `404` no encontrado (incluye cuando el recurso existe pero no pertenece al usuario/alcance) +- `500` error interno (sanitizado, sin detalle de stack en producción) diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md new file mode 100644 index 0000000..d706492 --- /dev/null +++ b/BASE_DE_DATOS.md @@ -0,0 +1,147 @@ +# Base de Datos - project.linkedcorp.net + +## Motor + +- Motor: MariaDB +- Base principal: `project_linkedcorp` +- Host desarrollo: `192.168.222.20` +- Host producción: `192.168.222.32` +- Puerto: `3306` +- Charset: `utf8mb4` +- Collation: `utf8mb4_unicode_ci` + +## Fuente de verdad + +- La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`). +- Archivo SQL base: `backend/database/migrations/` (SQL idempotente, numerado, `CREATE TABLE IF NOT EXISTS`). +- Seeds: `backend/database/seeds/` (roles, catálogos, usuario admin de dev). + +## Tablas principales (MVP) + +| Tabla | Contenido | +|---|---| +| `roles` | catálogo fijo de 5 roles (admin, it_manager, project_lead, collaborator, viewer) | +| `users` | usuarios, con rol y departamento | +| `departments` | departamentos/áreas, jerarquía simple vía `parent_id` | +| `project_types` | catálogo de tipos de proyecto | +| `priorities` | catálogo de prioridades (baja/media/alta/crítica) | +| `project_statuses` | catálogo de estados de proyecto | +| `activity_statuses` | catálogo de estados de actividad | +| `projects` | proyectos | +| `project_members` | usuarios asignados a un proyecto (reemplaza "equipos" como módulo aparte) | +| `activities` | actividades de un proyecto | +| `activity_dependencies` | dependencias simples entre actividades ("depende de") | +| `subtasks` | checklist liviano dentro de una actividad | +| `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) | +| `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) | + +## Relaciones clave + +- `users.role_id -> roles.id`, `users.department_id -> departments.id` +- `departments.parent_id -> departments.id` (jerarquía opcional, ej. TI → Desarrollo) +- `projects.status_id -> project_statuses.id`, `projects.priority_id -> priorities.id`, + `projects.project_type_id -> project_types.id`, `projects.owner_user_id -> users.id`, + `projects.requesting_department_id / responsible_department_id -> departments.id` +- `project_members.project_id -> projects.id`, `project_members.user_id -> users.id` +- `activities.project_id -> projects.id`, `activities.status_id -> activity_statuses.id`, + `activities.priority_id -> priorities.id`, `activities.responsible_user_id -> users.id` +- `activity_dependencies.activity_id / depends_on_activity_id -> activities.id` +- `subtasks.activity_id -> activities.id`, `subtasks.responsible_user_id -> users.id` (nullable) +- `comments.entity_id` / `attachments.entity_id` apuntan a `projects.id` o `activities.id` según + `entity_type` (sin FK física cruzada — se valida en el servicio) + +## Reglas de datos + +- `projects.progress_pct` y `activities.progress_pct` son **calculados por el servicio**, no + editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin + subtareas = manual (0-100); proyecto = promedio del avance de sus actividades. +- `users.password_hash` con bcrypt, nunca texto plano ni reversible. +- Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios con historial. +- 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. + +## Riesgos o decisiones + +- Sin triggers de BD para recalcular avances: toda la lógica vive en la capa `service/` del backend + (más simple de depurar y de extender en fase 2). +- `risk_level` es un campo simple en `projects` (bajo/medio/alto) — el módulo completo de Riesgos + (probabilidad/impacto/mitigación) queda para fase 2, sin necesitar migrar este campo. + +## Archivos relacionados + +- `backend/.env.example` +- `backend/database/migrations/*.sql` +- `backend/database/seeds/*.sql` + +--- + +## Crear la base de datos en DESARROLLO (.20) + +Ya ejecutado para este proyecto (2026-09-03): + +```bash +bash /home/mgonzalez/desarrollo/project-md-templates/crear-db-dev.sh project_linkedcorp project_linkedcorp +``` + +BD `project_linkedcorp` y usuario `project_linkedcorp`@`192.168.222.40` creados en `192.168.222.20`. +Las credenciales están en `backend/.env` (no versionado). + +Verificar: + +```bash +mysql --defaults-extra-file=~/.config/mysql/dev20-admin.cnf -e "SHOW DATABASES LIKE 'project_linkedcorp';" +``` + +Aplicar migraciones/seeds (una vez creado `backend/src/scripts/migrate-db.js`): + +```bash +cd backend +set -a && source .env && set +a +node src/scripts/migrate-db.js +node src/scripts/seed-db.js +``` + +--- + +## Setup de base de datos en producción (primera vez) — pendiente, no ejecutado aún + +El servidor `.32` no tiene SSH accesible; el acceso admin es solo desde la máquina de desarrollo. +Credenciales de acceso admin: ver `SERVIDORES.md` (project-md-templates/, no versionado). + +```bash +mysql -h 192.168.222.32 -u mgonzalez -p --ssl=0 +``` + +```sql +CREATE DATABASE IF NOT EXISTS project_linkedcorp + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +CREATE USER IF NOT EXISTS 'project_linkedcorp'@'192.168.222.3' + IDENTIFIED BY '[DB_PASSWORD_PROD]'; + +GRANT ALL PRIVILEGES ON project_linkedcorp.* TO 'project_linkedcorp'@'192.168.222.3'; + +FLUSH PRIVILEGES; +``` + +Migración en el servidor de aplicaciones (`.3`), cuando exista el deploy: + +```bash +ssh deploy@192.168.222.3 -p 2203 +cd /opt/apps/project.linkedcorp.net/source/backend +set -a && source /opt/apps/project.linkedcorp.net/backend/.env && set +a +node src/scripts/migrate-db.js +node src/scripts/seed-db.js # solo catálogos/roles; NO recrear usuarios de prueba de dev +``` + +--- + +## Actualizaciones de esquema en producción + +Método: archivos SQL idempotentes en `backend/database/migrations/00X-nombre.sql` +(`CREATE TABLE IF NOT EXISTS` / `information_schema` guards), aplicados con +`node src/scripts/migrate-db.js`. Siempre backup (`mysqldump --single-transaction`) antes de migrar +en producción. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6295758 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog - project.linkedcorp.net + +## [0.1.0] - 2026-09-03 +### Added +- Estructura inicial del proyecto (backend Express + frontend React/Vite + MariaDB). +- Modelo de datos MVP: usuarios/roles, departamentos, proyectos, actividades, subtareas, + dependencias, comentarios, adjuntos, notificaciones, auditoría, catálogos. +- Identidad UI/UX Fluent/Windows 11 (UI-UX-WINDOWS) adaptada a app web. diff --git a/CONFIGURACION.md b/CONFIGURACION.md new file mode 100644 index 0000000..879f066 --- /dev/null +++ b/CONFIGURACION.md @@ -0,0 +1,84 @@ +# Configuracion - project.linkedcorp.net + +## Requisitos + +- Node.js: 20.x LTS +- npm: 10.x +- MariaDB: servidor dev `192.168.222.20` (ya existe la BD `project_linkedcorp`) + +## Variables principales + +Usar `backend/.env.example` y `frontend/.env.example` como base. + +- copiar `.env.example` -> `.env` en cada servicio +- completar `.env` real antes de pruebas de conexión +- leer la configuración desde `.env` +- versionar `.env.example`; nunca versionar `.env` + +`backend/.env`: + +```env +PORT=4019 +NODE_ENV=development + +DB_HOST=192.168.222.20 +DB_PORT=3306 +DB_NAME=project_linkedcorp +DB_USER=project_linkedcorp +DB_PASSWORD= + +JWT_SECRET= +JWT_EXPIRES=8h + +CORS_ORIGIN=http://localhost:5196 + +STORAGE_ROOT=./storage +``` + +`frontend/.env`: + +```env +VITE_API_URL=/api +``` + +## Setup local + +### Backend + +```bash +cd backend +npm install +npm run dev +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +**React + Vite — proxy de desarrollo y acceso desde el teléfono** (`frontend/vite.config.js`): en +dev, el frontend reenvía `/api` al backend (evita CORS) y escucha en `0.0.0.0` para probar desde el +celular. En producción, cada servicio corre por separado (arquetipo B): el frontend sirve su propio +`dist` y el backend expone `/api`; NPM enruta `/api` al backend en el dominio público. + +```js +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5196, + strictPort: true, + proxy: { '/api': 'http://localhost:4019' }, + }, +}) +``` + +## Verificaciones + +- Frontend responde en `5196` (`http://192.168.222.20x:5196` o `localhost:5196`). +- Backend responde en `4019` (`curl http://localhost:4019/health`). +- Conexión a base válida (`node -e "require('./src/config/db')" ` o el log de arranque del servidor). +- `.env.example` alineado con el código (nuevas variables se agregan también ahí). diff --git a/ESTADO.md b/ESTADO.md new file mode 100644 index 0000000..87e950f --- /dev/null +++ b/ESTADO.md @@ -0,0 +1,84 @@ +# ESTADO — project.linkedcorp.net + +Ultima actualizacion: 2026-09-03 +Fase actual: MVP funcional en desarrollo local (fase 1 completa, sin deploy a produccion). + +## Como retomar + +1. Leer primero el orquestador: `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt`. +2. Leer `AGENTS.md`, `SPEC.md` y este archivo. +3. Backend: `cd backend && npm install && npm run dev` (puerto 4019). Requiere `backend/.env` + (no versionado — si falta, copiar `backend/.env.example` y completar con las credenciales de + la BD dev `project_linkedcorp` en `192.168.222.20`, usuario `project_linkedcorp`; la clave + generada la tiene el usuario, no esta en este archivo). +4. Frontend: `cd frontend && npm install && npm run dev` (puerto 5196, con proxy `/api` al backend). +5. Login de prueba (dev): `mgonzalez@linkedcorp.net` / clave `mgonzalez` (rol Administrador). +6. Migraciones/seed ya aplicados en la BD dev. Si se recrea la BD: `npm run migrate && npm run seed` + dentro de `backend/`. + +## Hecho y verificado (2026-09-03) + +- Repo/estructura inicial: `backend/` (Express + mysql2, arquitectura routes→controller→service→ + repository), `frontend/` (React + Vite, identidad Fluent/Windows), `docs/` (UI-UX-WINDOWS, + SEGURIDAD-Y-CALIDAD adaptada). +- Puertos reservados en `PORTS_INVENTORY.md` del grupo: frontend `5196`, backend `4019`. +- BD dev `project_linkedcorp` creada en `192.168.222.20` con `crear-db-dev.sh`; esquema aplicado + (`backend/database/migrations/001_initial.sql`) y seed cargado (roles, catalogos, departamentos + TI/Finanzas/RRHH/Comercial + sub-areas de TI, usuario admin de dev). +- Backend: modulos completos — auth (JWT + rate limit en login), users, departments, catalogs + (priorities/project-statuses/activity-statuses/project-types), projects (+ members), activities + (+ dependencies), subtasks, comments, attachments (multer, MIME whitelist), notifications, + dashboard (summary/calendar/gantt), audit. Recalculo automatico de `progress_pct` (actividad + desde subtareas, proyecto desde el promedio de sus actividades) verificado con pruebas manuales + via curl (login → crear proyecto → actividad → subtareas → avances correctos: 50%/50%). +- Frontend: identidad Fluent/Windows adaptada a web (sin barra de titulo falsa) — NavigationView, + ContentDialog, InfoBar, Toast, Flyout (menu contextual), ListView con orden/buscador, ComboBox + con buscador, Treeview, Tabs reales, Expander. Paginas: Login, Dashboard (KPIs + actividades + propias + proyectos por estado), Proyectos (listado + detalle con tabs Info/Actividades/Gantt/ + Calendario/Comentarios/Documentos, master-detail de actividades con subtareas/dependencias/ + comentarios/adjuntos), Organizacion (Departamentos en arbol + Usuarios), Configuracion + (apariencia + editor de los 4 catalogos). i18n es-EC/en-US, tema claro/oscuro y modo de botones + persistidos y funcionando. +- Verificacion end-to-end con Playwright (headless, instalado solo en el entorno de trabajo, no en + el proyecto): login, dashboard, listado y detalle de proyecto con las 6 pestañas, detalle de + actividad con subtareas, organizacion (arbol + usuarios), configuracion, cambio a tema claro, + creacion de un proyecto nuevo de punta a punta, y el ContentDialog de confirmacion de borrado — + todo sin errores de consola. Capturas no se conservaron en el repo (solo en el entorno temporal + de la sesion). + +## Pendiente (fase 2, sin comprometer lo ya construido) + +- Riesgos e Incidencias como modulos propios (hoy: `risk_level` simple en el proyecto). +- Solicitudes/requerimientos formales de otras areas. +- Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos. +- Registro de horas trabajadas detallado (bitacora diaria). +- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada. +- Permisos configurables por modulo × accion desde la UI (hoy: roles fijos codificados en + `backend/src/middleware/auth.js` y en cada `service.js`). +- Notificaciones por email/WhatsApp (hoy: solo in-app). +- Job programado para detectar actividades vencidas/por vencer y generar notificaciones + automaticas (hoy: la notificacion solo se dispara al asignar/reasignar una actividad). +- DatePicker/ComboBox: se uso `` nativo en vez de un flyout de calendario + propio (simplificacion deliberada para el MVP; UI-UX-WINDOWS.md recomienda uno propio). + +## Deploy a produccion — NO ejecutado + +Pendiente de que el usuario valide el MVP en local. Cuando se confirme, seguir +`BASE_DE_DATOS.md` (seccion produccion) y crear `PRODUCCION.md`/`DEPLOY_WEB.md` del proyecto +siguiendo el arquetipo B (dos servicios) descrito en el orquestador. + +## Decisiones clave + +- MVP simplificado respecto al listado original de 30 areas del usuario — detalle completo y + justificacion de cada simplificacion en `SPEC.md` (seccion "Fuera de alcance por ahora") y en el + plan de esta sesion. +- Identidad UI/UX: `UI-UX-WINDOWS.md` (Fluent/Windows 11) en vez de `UI-UX-LINKEDCORP.md` — + decision explicita del usuario. Adaptacion documentada en `AGENTS.md`. +- Empresa unica (single-company): no existe tabla `companies` en el MVP. +- Arquetipo de despliegue B (dos servicios separados) — decision explicita del usuario. + +## Deuda tecnica + +- `npm audit` en backend: 3 vulnerabilidades moderadas en `qs`/`body-parser`, transitivas de + Express 4.x (sin fix no-breaking disponible al 2026-09-03). No critico para uso interno; revisar + cuando Express publique el fix o evaluar migrar a Express 5 en fase 2. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad3150c --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# project.linkedcorp.net + +Gestión y seguimiento de proyectos y actividades de TI para una empresa de tamaño medio. MVP simple +por diseño (menos campos, menos módulos separados) con el modelo de datos preparado para crecer +hacia el resto del alcance funcional (riesgos, incidencias, recursos, proveedores, presupuesto, +reportes exportables, etc.) sin romper lo construido. + +## Stack + +- Frontend: React + Vite +- Backend: Node.js + Express +- Base de datos: MariaDB +- Deploy: Debian 12 (dos servicios separados — arquetipo B del grupo) + +## Estructura + +```text +project.linkedcorp.net/ +├── README.md +├── AGENTS.md +├── SPEC.md +├── API.md +├── BASE_DE_DATOS.md +├── CONFIGURACION.md +├── VERSIONADO.md +├── ESTADO.md +├── frontend/ +├── backend/ +├── docs/ +└── scripts/ +``` + +## Modulos principales (MVP) + +- Seguridad (usuarios, roles, auditoría) +- Organización (departamentos, usuarios) +- Proyectos, Actividades y Subtareas +- Dashboard, Calendario, Gantt (solo lectura) +- Comentarios, adjuntos, notificaciones in-app +- Configuración (catálogos) + +## Arranque rapido + +### Backend + +```bash +cd backend +npm install +cp .env.example .env # completar con las credenciales reales +npm run dev +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Puertos de este proyecto (registrados en `PORTS_INVENTORY.md` del grupo): frontend `5196`, +backend `4019`. El frontend escucha en `0.0.0.0` para poder probarlo desde otro equipo/celular en +la misma red. + +## Variables y configuracion + +- Revisar `backend/.env.example` y `CONFIGURACION.md`. +- Base de datos dev: `project_linkedcorp` en `192.168.222.20` (ya creada). + +## Documentacion relacionada + +- `AGENTS.md` — contexto del proyecto para el asistente de IA (leer primero el orquestador del grupo). +- `SPEC.md` — alcance funcional. +- `API.md` — contrato de la API. +- `BASE_DE_DATOS.md` — modelo de datos. +- `CONFIGURACION.md` — variables de entorno y setup local. +- `docs/UI-UX-WINDOWS.md` — identidad visual del proyecto (Fluent/Windows 11). +- `ESTADO.md` — punto de retoma del proyecto (handoff). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..64b45fb --- /dev/null +++ b/SPEC.md @@ -0,0 +1,110 @@ +# SPEC - project.linkedcorp.net + +## Objetivo + +Sistema interno para que una empresa de tamaño medio controle los proyectos y actividades de TI que +debe cumplir: qué proyectos hay, quién es responsable de qué, qué está atrasado y qué avance real +tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa día a día. + +## Problema que resuelve + +- Hoy no hay un lugar único donde ver proyectos activos, actividades pendientes/vencidas y de quién + es cada una. El Jefe de TI necesita esa foto al entrar, sin perseguir a cada persona. + +## Usuarios o roles + +| Rol | Alcance | +|---|---| +| Administrador | Todo, incluida gestión de usuarios/roles/catálogos | +| Jefe de TI | Ve y gestiona todos los proyectos/actividades, aprueba; no administra usuarios | +| Coordinador / Líder de proyecto | Crea/edita los proyectos donde es responsable o miembro, gestiona sus actividades | +| Colaborador | Ve y actualiza lo asignado a él (actividades/subtareas propias), comenta | +| Consultor | Solo lectura en todo | + +## Modulos (MVP) + +### 1. Seguridad +- Login/logout con JWT, sesión persistida en el navegador. +- Usuarios: alta/edición por el Administrador (nombre, email, rol, departamento, cargo, activo/inactivo). +- Auditoría central (`audit_log`): quién hizo qué, sobre qué entidad, cuándo. + +### 2. Organización +- Departamentos (con sub-área opcional vía `parent_id`, ej. TI → Desarrollo). +- Usuarios pertenecen a un departamento y tienen un cargo (texto libre). + +### 3. Proyectos +- Código autogenerado, nombre, descripción, objetivo, tipo, prioridad, estado, responsable, + miembros del equipo, fechas (inicio, fin estimado, fin real), % avance (calculado desde las + actividades), departamento solicitante y responsable, nivel de riesgo (bajo/medio/alto), notas. +- Estados: Planificado, En preparación, En ejecución, En pausa, Bloqueado, Completado, Cancelado. + +### 4. Actividades +- Código, nombre, descripción, responsable, prioridad, estado, fecha inicio, fecha límite, fecha + real de fin, % avance, marca de hito (`is_milestone`), horas estimadas, dependencias simples + ("depende de" otra actividad del mismo proyecto). +- Estados: Pendiente, En progreso, En revisión, Bloqueada, En espera, Completada, Cancelada. + +### 5. Subtareas +- Checklist liviano dentro de una actividad: nombre, responsable opcional, hecho/no hecho, fecha + límite opcional. Si una actividad tiene subtareas, su avance se calcula solo desde ellas. + +### 6. Dashboard +- KPIs: proyectos activos, proyectos atrasados, actividades pendientes/vencidas/para hoy, avance + promedio. Lista de actividades vencidas y de hoy del usuario. + +### 7. Calendario +- Vista mensual de actividades por fecha límite (incluye hitos marcados). + +### 8. Gantt +- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa + desde el gráfico en el MVP. + +### 9. Comentarios y adjuntos +- Hilo de comentarios por proyecto y por actividad. +- Adjuntar archivos (evidencias) a proyecto o actividad. + +### 10. Notificaciones +- In-app (campana con contador): asignación de actividad, actividad vencida/por vencer (job diario), + cambio de estado relevante. + +### 11. Configuración +- Catálogos editables por el Administrador: estados de proyecto, estados de actividad, prioridades, + tipos de proyecto, departamentos. Vienen con datos semilla — no requieren configuración inicial. +- Preferencias de interfaz: idioma es-EC/en-US, tema claro/oscuro, apariencia de botones. + +## Flujos clave + +1. Administrador crea departamentos y usuarios (con rol y departamento). +2. Jefe de TI o Coordinador crea un proyecto, asigna miembros, define fechas y prioridad. +3. Se crean actividades dentro del proyecto (con responsable, fechas, dependencias si aplica). +4. Cada responsable actualiza el avance de sus actividades/subtareas; el avance del proyecto se + recalcula solo. +5. El Jefe de TI entra al Dashboard y ve de un vistazo activos/atrasados/pendientes/avance. +6. Vencimientos se marcan automáticamente (🟢 en tiempo / 🟡 próximo a vencer / 🔴 vencido) y generan + notificación in-app al responsable. + +## Reglas funcionales importantes + +- El listado se ve primero; la creación se abre solo con el botón "Nuevo" (regla de UX del grupo). +- El % de avance de una actividad con subtareas es automático (no editable a mano); sin subtareas, + es manual (0-100). +- El % de avance de un proyecto es el promedio del avance de sus actividades (no editable a mano). +- Una actividad no puede marcarse "Completada" si depende de otra que no lo está (aviso, no bloqueo + duro en el MVP). +- Todo texto de interfaz sale de i18n (es-EC/en-US), nunca hardcodeado. + +## Fuera de alcance por ahora (fase 2+) + +- Riesgos e Incidencias como módulos propios (hoy: `risk_level`/notas en el proyecto). +- Solicitudes/requerimientos formales de otras áreas. +- Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos. +- Registro de horas trabajadas detallado (bitácora diaria). +- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada. +- Permisos configurables por módulo × acción desde la UI. +- Notificaciones por email/WhatsApp. +- Equipos de trabajo como entidad propia multi-proyecto (hoy: miembros por proyecto). + +## Estado para produccion + +- Completado: en desarrollo (MVP en construcción). +- Bloqueantes: ninguno conocido; deploy a producción pendiente de validación del MVP en local. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/VERSIONADO.md b/VERSIONADO.md new file mode 100644 index 0000000..ff7bf72 --- /dev/null +++ b/VERSIONADO.md @@ -0,0 +1,33 @@ +# Versionado - project.linkedcorp.net + +## Regla principal + +`.gitignore` ya copiado y ajustado desde `GITIGNORE_TEMPLATE.md`. Versionado `X.Y.Z`. + +- `X`: cambio fuerte o mayor (ruptura de compatibilidad, cambios grandes de arquitectura/BD/flujos) +- `Y`: cambio medio (módulo nuevo, funcionalidad nueva completa) +- `Z`: cambio bajo (correcciones, ajustes menores, hotfixes) + +## Si se debe versionar + +- Código fuente, `README.md`, `AGENTS.md`, `docs/`, `SPEC.md`, `API.md`, `BASE_DE_DATOS.md`, + `CONFIGURACION.md`, `.env.example`, `package.json` + lockfile, `database/migrations`, + `database/seeds`, `scripts/`. + +## No se debe versionar + +- `.env`, secretos/tokens/llaves, `node_modules/`, builds, logs, `storage/`, `backend/uploads/`, + archivos temporales, contexto local de despliegue. + +## Regla operativa + +- Antes de producción, revisar si corresponde subir versión. +- Toda versión nueva se refleja en `VERSION` y `CHANGELOG.md`. +- Usar `scripts/set-version.sh X.Y.Z` para actualizar todos los archivos de versión de una vez. + +## Script set-version.sh + +Ver `scripts/set-version.sh` en este proyecto (copiado de la plantilla del grupo, sin sección +Android porque este proyecto no tiene app móvil). + +Uso: `./scripts/set-version.sh 0.1.0` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..967050d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +PORT=4019 +NODE_ENV=development + +DB_HOST=192.168.222.20 +DB_PORT=3306 +DB_NAME=project_linkedcorp +DB_USER=project_linkedcorp +DB_PASSWORD= + +JWT_SECRET= +JWT_EXPIRES=8h + +CORS_ORIGIN=http://localhost:5196 + +STORAGE_ROOT=./storage diff --git a/backend/database/migrations/001_initial.sql b/backend/database/migrations/001_initial.sql new file mode 100644 index 0000000..06cb16a --- /dev/null +++ b/backend/database/migrations/001_initial.sql @@ -0,0 +1,196 @@ +-- project.linkedcorp.net — esquema inicial (MVP). Idempotente: seguro correr sobre BD existente. +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(30) NOT NULL UNIQUE, + name VARCHAR(80) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS departments ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + parent_id INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_departments_parent FOREIGN KEY (parent_id) REFERENCES departments(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR(160) NOT NULL UNIQUE, + password_hash VARCHAR(120) NOT NULL, + role_id INT NOT NULL, + department_id INT NULL, + position VARCHAR(120) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_users_role FOREIGN KEY (role_id) REFERENCES roles(id), + CONSTRAINT fk_users_department FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS project_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS priorities ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(40) NOT NULL UNIQUE, + level INT NOT NULL, + color VARCHAR(20) NOT NULL DEFAULT 'accent' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS project_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(60) NOT NULL UNIQUE, + sort_order INT NOT NULL DEFAULT 0, + color VARCHAR(20) NOT NULL DEFAULT 'accent', + is_closed TINYINT(1) NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS activity_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(60) NOT NULL UNIQUE, + sort_order INT NOT NULL DEFAULT 0, + color VARCHAR(20) NOT NULL DEFAULT 'accent', + is_closed TINYINT(1) NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS projects ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + description TEXT NULL, + objective TEXT NULL, + project_type_id INT NULL, + priority_id INT NOT NULL, + status_id INT NOT NULL, + owner_user_id INT NOT NULL, + requesting_department_id INT NULL, + responsible_department_id INT NULL, + start_date DATE NULL, + estimated_end_date DATE NULL, + actual_end_date DATE NULL, + progress_pct DECIMAL(5,2) NOT NULL DEFAULT 0, + risk_level ENUM('low','medium','high') NOT NULL DEFAULT 'low', + notes TEXT NULL, + created_by INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_projects_type FOREIGN KEY (project_type_id) REFERENCES project_types(id) ON DELETE SET NULL, + CONSTRAINT fk_projects_priority FOREIGN KEY (priority_id) REFERENCES priorities(id), + CONSTRAINT fk_projects_status FOREIGN KEY (status_id) REFERENCES project_statuses(id), + CONSTRAINT fk_projects_owner FOREIGN KEY (owner_user_id) REFERENCES users(id), + CONSTRAINT fk_projects_req_dept FOREIGN KEY (requesting_department_id) REFERENCES departments(id) ON DELETE SET NULL, + CONSTRAINT fk_projects_resp_dept FOREIGN KEY (responsible_department_id) REFERENCES departments(id) ON DELETE SET NULL, + CONSTRAINT fk_projects_created_by FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS project_members ( + project_id INT NOT NULL, + user_id INT NOT NULL, + role_in_project VARCHAR(60) NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id), + CONSTRAINT fk_pm_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_pm_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS activities ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + description TEXT NULL, + responsible_user_id INT NULL, + priority_id INT NOT NULL, + status_id INT NOT NULL, + start_date DATE NULL, + due_date DATE NULL, + actual_end_date DATE NULL, + progress_pct DECIMAL(5,2) NOT NULL DEFAULT 0, + is_milestone TINYINT(1) NOT NULL DEFAULT 0, + estimated_hours DECIMAL(6,2) NULL, + created_by INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_activities_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_activities_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_activities_priority FOREIGN KEY (priority_id) REFERENCES priorities(id), + CONSTRAINT fk_activities_status FOREIGN KEY (status_id) REFERENCES activity_statuses(id), + CONSTRAINT fk_activities_created_by FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS activity_dependencies ( + activity_id INT NOT NULL, + depends_on_activity_id INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (activity_id, depends_on_activity_id), + CONSTRAINT fk_dep_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + CONSTRAINT fk_dep_depends_on FOREIGN KEY (depends_on_activity_id) REFERENCES activities(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subtasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + activity_id INT NOT NULL, + name VARCHAR(160) NOT NULL, + responsible_user_id INT NULL, + is_done TINYINT(1) NOT NULL DEFAULT 0, + due_date DATE NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_subtasks_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + CONSTRAINT fk_subtasks_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + entity_type ENUM('project','activity') NOT NULL, + entity_id INT NOT NULL, + user_id INT NOT NULL, + message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_comments_user FOREIGN KEY (user_id) REFERENCES users(id), + INDEX idx_comments_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS attachments ( + id INT AUTO_INCREMENT PRIMARY KEY, + entity_type ENUM('project','activity') NOT NULL, + entity_id INT NOT NULL, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(120) NOT NULL, + size INT NOT NULL, + uploaded_by INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_attachments_user FOREIGN KEY (uploaded_by) REFERENCES users(id), + INDEX idx_attachments_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS notifications ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + type VARCHAR(60) NOT NULL, + message VARCHAR(255) NOT NULL, + entity_type ENUM('project','activity') NULL, + entity_id INT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_notifications_user (user_id, is_read) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + action VARCHAR(60) NOT NULL, + entity_type VARCHAR(40) NOT NULL, + entity_id INT NULL, + detail_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_audit_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..0229b05 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1248 @@ +{ + "name": "project-linkedcorp-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "project-linkedcorp-backend", + "version": "0.1.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^2.3.0", + "mysql2": "^3.11.0" + } + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql2": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b19a432 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,24 @@ +{ + "name": "project-linkedcorp-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/server.js", + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js", + "migrate": "node src/scripts/migrate-db.js", + "seed": "node src/scripts/seed-db.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^2.3.0", + "mysql2": "^3.11.0" + } +} diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..7671621 --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,42 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { env } from './config/env.js'; +import { notFoundHandler, errorHandler } from './middleware/errorHandler.js'; + +import authRoutes from './modules/auth/routes.js'; +import usersRoutes from './modules/users/routes.js'; +import departmentsRoutes from './modules/departments/routes.js'; +import catalogsRoutes from './modules/catalogs/routes.js'; +import projectsRoutes from './modules/projects/routes.js'; +import activitiesRoutes from './modules/activities/routes.js'; +import subtasksRoutes from './modules/subtasks/routes.js'; +import commentsRoutes from './modules/comments/routes.js'; +import attachmentsRoutes from './modules/attachments/routes.js'; +import notificationsRoutes from './modules/notifications/routes.js'; +import dashboardRoutes from './modules/dashboard/routes.js'; +import auditRoutes from './modules/audit/routes.js'; + +export const app = express(); + +app.use(helmet({ contentSecurityPolicy: false })); +app.use(cors({ origin: env.corsOrigin, credentials: true })); +app.use(express.json({ limit: '2mb' })); + +app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null })); + +app.use('/api/auth', authRoutes); +app.use('/api/users', usersRoutes); +app.use('/api/departments', departmentsRoutes); +app.use('/api/catalogs', catalogsRoutes); +app.use('/api/projects', projectsRoutes); +app.use('/api/activities', activitiesRoutes); +app.use('/api', subtasksRoutes); +app.use('/api/comments', commentsRoutes); +app.use('/api/attachments', attachmentsRoutes); +app.use('/api/notifications', notificationsRoutes); +app.use('/api/dashboard', dashboardRoutes); +app.use('/api/audit', auditRoutes); + +app.use(notFoundHandler); +app.use(errorHandler); diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..aa66963 --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,13 @@ +import mysql from 'mysql2/promise'; +import { env } from './env.js'; + +export const pool = mysql.createPool({ + host: env.db.host, + port: env.db.port, + database: env.db.name, + user: env.db.user, + password: env.db.password, + waitForConnections: true, + connectionLimit: 10, + dateStrings: true, +}); diff --git a/backend/src/config/env.js b/backend/src/config/env.js new file mode 100644 index 0000000..3ab336d --- /dev/null +++ b/backend/src/config/env.js @@ -0,0 +1,32 @@ +import 'dotenv/config'; + +const required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET']; + +for (const key of required) { + if (!process.env[key]) { + if (process.env.NODE_ENV === 'production') { + throw new Error(`Falta la variable de entorno obligatoria: ${key}`); + } + console.warn(`[env] Falta ${key} — completa backend/.env`); + } +} + +if (process.env.NODE_ENV === 'production' && (process.env.JWT_SECRET || '').length < 32) { + throw new Error('JWT_SECRET debe tener al menos 32 caracteres en producción'); +} + +export const env = { + port: Number(process.env.PORT || 4019), + nodeEnv: process.env.NODE_ENV || 'development', + db: { + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 3306), + name: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + }, + jwtSecret: process.env.JWT_SECRET || 'dev-only-insecure-secret', + jwtExpires: process.env.JWT_EXPIRES || '8h', + corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5196', + storageRoot: process.env.STORAGE_ROOT || './storage', +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..a664dba --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,58 @@ +import jwt from 'jsonwebtoken'; +import { env } from '../config/env.js'; +import { pool } from '../config/db.js'; +import { fail } from '../utils/http.js'; + +export async function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return fail(res, 401, 'No autenticado'); + + let payload; + try { + payload = jwt.verify(token, env.jwtSecret); + } catch { + return fail(res, 401, 'Token invalido o expirado'); + } + + // Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta). + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.is_active, u.department_id, r.code AS role_code + FROM users u JOIN roles r ON r.id = u.role_id + WHERE u.id = ? LIMIT 1`, + [payload.sub] + ); + const user = rows[0]; + if (!user || !user.is_active) return fail(res, 401, 'Cuenta inactiva o inexistente'); + + req.currentUser = { + id: user.id, + name: user.name, + email: user.email, + roleCode: user.role_code, + departmentId: user.department_id, + }; + next(); +} + +export function requireRole(...roleCodes) { + return (req, res, next) => { + if (!req.currentUser) return fail(res, 401, 'No autenticado'); + if (!roleCodes.includes(req.currentUser.roleCode)) { + return fail(res, 403, 'No tienes permisos para esta accion'); + } + next(); + }; +} + +export const ROLES = { + ADMIN: 'admin', + IT_MANAGER: 'it_manager', + PROJECT_LEAD: 'project_lead', + COLLABORATOR: 'collaborator', + VIEWER: 'viewer', +}; + +export const CAN_MANAGE_CATALOGS = [ROLES.ADMIN]; +export const CAN_MANAGE_USERS = [ROLES.ADMIN]; +export const CAN_WRITE = [ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR]; diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..04937d5 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,18 @@ +import { HttpError } from '../utils/http.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ success: false, data: null, error: 'Ruta no encontrada' }); +} + +export function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars + if (err instanceof HttpError) { + return res.status(err.status).json({ success: false, data: null, error: err.message }); + } + console.error('[error]', err); + const isProd = process.env.NODE_ENV === 'production'; + res.status(500).json({ + success: false, + data: null, + error: isProd ? 'Error interno' : err.message, + }); +} diff --git a/backend/src/middleware/upload.js b/backend/src/middleware/upload.js new file mode 100644 index 0000000..bc0a4bd --- /dev/null +++ b/backend/src/middleware/upload.js @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import multer from 'multer'; +import { env } from '../config/env.js'; + +const ALLOWED_MIME = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', + 'application/zip', +]); + +const uploadsDir = path.join(env.storageRoot, 'attachments'); +fs.mkdirSync(uploadsDir, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, uploadsDir), + // Nombre generado por el servidor: evita path traversal y colisiones. + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).slice(0, 10); + cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`); + }, +}); + +export const upload = multer({ + storage, + limits: { fileSize: 15 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!ALLOWED_MIME.has(file.mimetype)) { + return cb(new Error('Tipo de archivo no permitido')); + } + cb(null, true); + }, +}); + +export { uploadsDir }; diff --git a/backend/src/modules/activities/controller.js b/backend/src/modules/activities/controller.js new file mode 100644 index 0000000..6296415 --- /dev/null +++ b/backend/src/modules/activities/controller.js @@ -0,0 +1,28 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getActivitiesByProject = asyncHandler(async (req, res) => { + ok(res, await service.listByProject(Number(req.params.projectId))); +}); + +export const postActivity = asyncHandler(async (req, res) => { + ok(res, await service.createActivity(Number(req.params.projectId), req.body, req.currentUser), 201); +}); + +export const getActivity = asyncHandler(async (req, res) => { + ok(res, await service.getActivity(Number(req.params.id))); +}); + +export const putActivity = asyncHandler(async (req, res) => { + ok(res, await service.updateActivity(Number(req.params.id), req.body, req.currentUser)); +}); + +export const postDependency = asyncHandler(async (req, res) => { + const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser); + ok(res, deps, 201); +}); + +export const deleteDependency = asyncHandler(async (req, res) => { + const deps = await service.removeDependency(Number(req.params.id), Number(req.params.dependsOnId), req.currentUser); + ok(res, deps); +}); diff --git a/backend/src/modules/activities/repository.js b/backend/src/modules/activities/repository.js new file mode 100644 index 0000000..a5675b4 --- /dev/null +++ b/backend/src/modules/activities/repository.js @@ -0,0 +1,103 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = ` + SELECT a.id, a.project_id, a.code, a.name, a.description, a.responsible_user_id, u.name AS responsible_name, + a.priority_id, pr.name AS priority_name, pr.color AS priority_color, + a.status_id, st.name AS status_name, st.color AS status_color, st.is_closed AS status_is_closed, + a.start_date, a.due_date, a.actual_end_date, a.progress_pct, a.is_milestone, + a.estimated_hours, a.created_by, a.created_at, a.updated_at + FROM activities a + JOIN priorities pr ON pr.id = a.priority_id + JOIN activity_statuses st ON st.id = a.status_id + LEFT JOIN users u ON u.id = a.responsible_user_id +`; + +export async function findByProject(projectId) { + const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? ORDER BY a.start_date, a.id`, [projectId]); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query(`${LIST_SELECT} WHERE a.id = ?`, [id]); + return rows[0] || null; +} + +export async function create(projectId, data) { + const [result] = await pool.query( + `INSERT INTO activities + (project_id, code, name, description, responsible_user_id, priority_id, status_id, + start_date, due_date, is_milestone, estimated_hours, created_by) + VALUES (?, 'PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + projectId, data.name, data.description || null, data.responsibleUserId || null, + data.priorityId, data.statusId, data.startDate || null, data.dueDate || null, + data.isMilestone ? 1 : 0, data.estimatedHours || null, data.createdBy, + ] + ); + const id = result.insertId; + const code = `ACT-${String(id).padStart(4, '0')}`; + await pool.query('UPDATE activities SET code = ? WHERE id = ?', [code, id]); + return findById(id); +} + +export async function update(id, data) { + await pool.query( + `UPDATE activities SET + name = ?, description = ?, responsible_user_id = ?, priority_id = ?, status_id = ?, + start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ? + WHERE id = ?`, + [ + data.name, data.description || null, data.responsibleUserId || null, data.priorityId, data.statusId, + data.startDate || null, data.dueDate || null, data.actualEndDate || null, + data.isMilestone ? 1 : 0, data.estimatedHours || null, id, + ] + ); + return findById(id); +} + +export async function updateProgress(id, progressPct) { + await pool.query('UPDATE activities SET progress_pct = ? WHERE id = ?', [progressPct, id]); +} + +export async function countSubtasks(id) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM subtasks WHERE activity_id = ?', [id]); + return rows[0].c; +} + +export async function listDependencies(id) { + const [rows] = await pool.query( + `SELECT d.depends_on_activity_id AS id, a.code, a.name, a.status_id, st.is_closed AS status_is_closed + FROM activity_dependencies d + JOIN activities a ON a.id = d.depends_on_activity_id + JOIN activity_statuses st ON st.id = a.status_id + WHERE d.activity_id = ?`, + [id] + ); + return rows; +} + +export async function addDependency(activityId, dependsOnActivityId) { + await pool.query( + 'INSERT IGNORE INTO activity_dependencies (activity_id, depends_on_activity_id) VALUES (?, ?)', + [activityId, dependsOnActivityId] + ); + return listDependencies(activityId); +} + +export async function removeDependency(activityId, dependsOnActivityId) { + await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? AND depends_on_activity_id = ?', [ + activityId, dependsOnActivityId, + ]); + return listDependencies(activityId); +} + +export async function findDueOrOverdue(userId, { onlyMine = false } = {}) { + const clauses = ["st.is_closed = 0", "a.due_date IS NOT NULL"]; + const params = []; + if (onlyMine) { clauses.push('a.responsible_user_id = ?'); params.push(userId); } + const [rows] = await pool.query( + `${LIST_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY a.due_date`, + params + ); + return rows; +} diff --git a/backend/src/modules/activities/routes.js b/backend/src/modules/activities/routes.js new file mode 100644 index 0000000..05852d7 --- /dev/null +++ b/backend/src/modules/activities/routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getActivity, putActivity, postDependency, deleteDependency } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/:id', getActivity); +router.put('/:id', putActivity); +router.post('/:id/dependencies', postDependency); +router.delete('/:id/dependencies/:dependsOnId', deleteDependency); + +export default router; diff --git a/backend/src/modules/activities/service.js b/backend/src/modules/activities/service.js new file mode 100644 index 0000000..2ec49d8 --- /dev/null +++ b/backend/src/modules/activities/service.js @@ -0,0 +1,130 @@ +import * as repo from './repository.js'; +import * as projectsRepo from '../projects/repository.js'; +import { recalcProjectProgress, canWriteProject } from '../projects/service.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { ROLES } from '../../middleware/auth.js'; +import { createNotification } from '../notifications/service.js'; + +async function getProjectOr404(projectId) { + const project = await projectsRepo.findById(projectId); + if (!project) throw new HttpError(404, 'Proyecto no encontrado'); + return project; +} + +// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es +// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros). +export async function canWriteActivity(user, project, activity) { + if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true; + if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project); + if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id; + return false; +} + +export const listByProject = (projectId) => repo.findByProject(projectId); + +export async function getActivity(id) { + const activity = await repo.findById(id); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const dependencies = await repo.listDependencies(id); + return { ...activity, dependencies }; +} + +export async function createActivity(projectId, payload, currentUser) { + const project = await getProjectOr404(projectId); + if (!(await canWriteProject(currentUser, project))) { + throw new HttpError(403, 'No tienes permisos para crear actividades en este proyecto'); + } + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + const activity = await repo.create(projectId, { ...payload, createdBy: currentUser.id }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'activity', entityId: activity.id }); + + if (activity.responsible_user_id && activity.responsible_user_id !== currentUser.id) { + await createNotification({ + userId: activity.responsible_user_id, + type: 'activity_assigned', + message: `Te asignaron la actividad "${activity.name}"`, + entityType: 'activity', + entityId: activity.id, + }); + } + await recalcProjectProgress(projectId); + return activity; +} + +export async function updateActivity(id, payload, currentUser) { + const activity = await repo.findById(id); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) { + throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + } + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + + // Colaborador no puede reasignar la actividad a otra persona ni cambiar avance manual si hay subtareas. + const nextResponsible = currentUser.roleCode === ROLES.COLLABORATOR + ? activity.responsible_user_id + : (payload.responsibleUserId ?? activity.responsible_user_id); + + const subtaskCount = await repo.countSubtasks(id); + const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible }); + + if (subtaskCount === 0 && payload.progressPct !== undefined) { + const pct = Math.max(0, Math.min(100, Number(payload.progressPct))); + await repo.updateProgress(id, pct); + updated.progress_pct = pct; + } + + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'activity', entityId: id }); + + if (nextResponsible && nextResponsible !== activity.responsible_user_id && nextResponsible !== currentUser.id) { + await createNotification({ + userId: nextResponsible, + type: 'activity_assigned', + message: `Te asignaron la actividad "${updated.name}"`, + entityType: 'activity', + entityId: id, + }); + } + + await recalcProjectProgress(activity.project_id); + return repo.findById(id); +} + +export async function addDependency(activityId, dependsOnActivityId, currentUser) { + if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma'); + const activity = await repo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const dependsOn = await repo.findById(dependsOnActivityId); + if (!dependsOn || dependsOn.project_id !== activity.project_id) { + throw new HttpError(400, 'La dependencia debe ser una actividad del mismo proyecto'); + } + const project = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + const deps = await repo.addDependency(activityId, dependsOnActivityId); + await logAudit({ userId: currentUser.id, action: 'add_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } }); + return deps; +} + +export async function removeDependency(activityId, dependsOnActivityId, currentUser) { + const activity = await repo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + const deps = await repo.removeDependency(activityId, dependsOnActivityId); + await logAudit({ userId: currentUser.id, action: 'remove_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } }); + return deps; +} + +// Recalcula el avance de una actividad a partir de sus subtareas (si tiene) y propaga al proyecto. +export async function recalcFromSubtasks(activityId, doneCount, totalCount) { + if (totalCount === 0) return; + const pct = Math.round((doneCount / totalCount) * 10000) / 100; + await repo.updateProgress(activityId, pct); + const activity = await repo.findById(activityId); + await recalcProjectProgress(activity.project_id); +} diff --git a/backend/src/modules/attachments/controller.js b/backend/src/modules/attachments/controller.js new file mode 100644 index 0000000..41c5e09 --- /dev/null +++ b/backend/src/modules/attachments/controller.js @@ -0,0 +1,51 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { ok, fail, asyncHandler, HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { ROLES } from '../../middleware/auth.js'; +import { uploadsDir } from '../../middleware/upload.js'; +import * as repo from './repository.js'; + +const VALID_ENTITY_TYPES = new Set(['project', 'activity']); + +export const getAttachments = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.query; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios'); + ok(res, await repo.findByEntity(entityType, Number(entityId))); +}); + +export const postAttachment = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.body || {}; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios'); + if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura'); + if (!req.file) throw new HttpError(400, 'Archivo obligatorio'); + + const attachment = await repo.create({ + entityType, entityId: Number(entityId), + filename: req.file.filename, originalName: req.file.originalname, + mimeType: req.file.mimetype, size: req.file.size, uploadedBy: req.currentUser.id, + }); + await logAudit({ userId: req.currentUser.id, action: 'upload', entityType, entityId: Number(entityId), detail: { attachmentId: attachment.id } }); + ok(res, attachment, 201); +}); + +export const downloadAttachment = asyncHandler(async (req, res) => { + const attachment = await repo.findById(Number(req.params.id)); + if (!attachment) throw new HttpError(404, 'Adjunto no encontrado'); + const filePath = path.join(uploadsDir, attachment.filename); + if (!fs.existsSync(filePath)) throw new HttpError(404, 'Archivo no encontrado en el servidor'); + res.download(filePath, attachment.original_name); +}); + +export const deleteAttachment = asyncHandler(async (req, res) => { + const attachment = await repo.findById(Number(req.params.id)); + if (!attachment) throw new HttpError(404, 'Adjunto no encontrado'); + const canDelete = [ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id; + if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto'); + + await repo.remove(attachment.id); + const filePath = path.join(uploadsDir, attachment.filename); + fs.promises.unlink(filePath).catch(() => {}); + await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } }); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/attachments/repository.js b/backend/src/modules/attachments/repository.js new file mode 100644 index 0000000..5348d31 --- /dev/null +++ b/backend/src/modules/attachments/repository.js @@ -0,0 +1,30 @@ +import { pool } from '../../config/db.js'; + +export async function findByEntity(entityType, entityId) { + const [rows] = await pool.query( + `SELECT a.id, a.entity_type, a.entity_id, a.filename, a.original_name, a.mime_type, a.size, + a.uploaded_by, u.name AS uploaded_by_name, a.created_at + FROM attachments a JOIN users u ON u.id = a.uploaded_by + WHERE a.entity_type = ? AND a.entity_id = ? ORDER BY a.created_at DESC`, + [entityType, entityId] + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query('SELECT * FROM attachments WHERE id = ?', [id]); + return rows[0] || null; +} + +export async function create({ entityType, entityId, filename, originalName, mimeType, size, uploadedBy }) { + const [result] = await pool.query( + `INSERT INTO attachments (entity_type, entity_id, filename, original_name, mime_type, size, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [entityType, entityId, filename, originalName, mimeType, size, uploadedBy] + ); + return findById(result.insertId); +} + +export async function remove(id) { + await pool.query('DELETE FROM attachments WHERE id = ?', [id]); +} diff --git a/backend/src/modules/attachments/routes.js b/backend/src/modules/attachments/routes.js new file mode 100644 index 0000000..2b103db --- /dev/null +++ b/backend/src/modules/attachments/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { upload } from '../../middleware/upload.js'; +import { getAttachments, postAttachment, downloadAttachment, deleteAttachment } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getAttachments); +router.post('/', upload.single('file'), postAttachment); +router.get('/:id/download', downloadAttachment); +router.delete('/:id', deleteAttachment); + +export default router; diff --git a/backend/src/modules/audit/controller.js b/backend/src/modules/audit/controller.js new file mode 100644 index 0000000..a158942 --- /dev/null +++ b/backend/src/modules/audit/controller.js @@ -0,0 +1,7 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as repo from './repository.js'; + +export const getAudit = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.query; + ok(res, await repo.findAll({ entityType, entityId: entityId ? Number(entityId) : undefined })); +}); diff --git a/backend/src/modules/audit/repository.js b/backend/src/modules/audit/repository.js new file mode 100644 index 0000000..593d749 --- /dev/null +++ b/backend/src/modules/audit/repository.js @@ -0,0 +1,16 @@ +import { pool } from '../../config/db.js'; + +export async function findAll({ entityType, entityId } = {}) { + const clauses = []; + const params = []; + if (entityType) { clauses.push('a.entity_type = ?'); params.push(entityType); } + if (entityId) { clauses.push('a.entity_id = ?'); params.push(entityId); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [rows] = await pool.query( + `SELECT a.id, a.user_id, u.name AS user_name, a.action, a.entity_type, a.entity_id, a.detail_json, a.created_at + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + ${where} ORDER BY a.created_at DESC LIMIT 200`, + params + ); + return rows; +} diff --git a/backend/src/modules/audit/routes.js b/backend/src/modules/audit/routes.js new file mode 100644 index 0000000..754eda6 --- /dev/null +++ b/backend/src/modules/audit/routes.js @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js'; +import { getAudit } from './controller.js'; + +const router = Router(); + +router.use(requireAuth, requireRole(ROLES.ADMIN, ROLES.IT_MANAGER)); +router.get('/', getAudit); + +export default router; diff --git a/backend/src/modules/auth/controller.js b/backend/src/modules/auth/controller.js new file mode 100644 index 0000000..aa485ca --- /dev/null +++ b/backend/src/modules/auth/controller.js @@ -0,0 +1,18 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import * as authService from './service.js'; + +export const postLogin = asyncHandler(async (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) return fail(res, 400, 'Email y clave son obligatorios'); + const result = await authService.login(email, password); + ok(res, result); +}); + +export const postLogout = asyncHandler(async (req, res) => { + // Stateless JWT: el cliente descarta el token. Se deja el endpoint para uso futuro (blacklist). + ok(res, { loggedOut: true }); +}); + +export const getMe = asyncHandler(async (req, res) => { + ok(res, req.currentUser); +}); diff --git a/backend/src/modules/auth/routes.js b/backend/src/modules/auth/routes.js new file mode 100644 index 0000000..161de59 --- /dev/null +++ b/backend/src/modules/auth/routes.js @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { requireAuth } from '../../middleware/auth.js'; +import { postLogin, postLogout, getMe } from './controller.js'; + +const router = Router(); + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, +}); + +router.post('/login', loginLimiter, postLogin); +router.post('/logout', requireAuth, postLogout); +router.get('/me', requireAuth, getMe); + +export default router; diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js new file mode 100644 index 0000000..20d547f --- /dev/null +++ b/backend/src/modules/auth/service.js @@ -0,0 +1,36 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { pool } from '../../config/db.js'; +import { env } from '../../config/env.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +export async function login(email, password) { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.department_id, r.code AS role_code, r.name AS role_name + FROM users u JOIN roles r ON r.id = u.role_id + WHERE u.email = ? LIMIT 1`, + [email] + ); + const user = rows[0]; + if (!user || !user.is_active) throw new HttpError(401, 'Credenciales invalidas'); + + const validPassword = await bcrypt.compare(password, user.password_hash); + if (!validPassword) throw new HttpError(401, 'Credenciales invalidas'); + + const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires }); + + await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id }); + + return { + token, + user: { + id: user.id, + name: user.name, + email: user.email, + roleCode: user.role_code, + roleName: user.role_name, + departmentId: user.department_id, + }, + }; +} diff --git a/backend/src/modules/catalogs/controller.js b/backend/src/modules/catalogs/controller.js new file mode 100644 index 0000000..b4d2cbf --- /dev/null +++ b/backend/src/modules/catalogs/controller.js @@ -0,0 +1,43 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { CATALOGS } from './repository.js'; +import * as repo from './repository.js'; + +function validKey(req, res) { + const key = req.params.catalog; + if (!CATALOGS[key]) { + fail(res, 404, 'Catalogo no encontrado'); + return null; + } + return key; +} + +export const getCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + ok(res, await repo.findAll(key)); +}); + +export const postCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + const item = await repo.create(key, req.body); + await logAudit({ userId: req.currentUser.id, action: 'create', entityType: `catalog:${key}`, entityId: item.id }); + ok(res, item, 201); +}); + +export const putCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + const item = await repo.update(key, Number(req.params.id), req.body); + await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id }); + ok(res, item); +}); + +export const deleteCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + await repo.remove(key, Number(req.params.id)); + await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: `catalog:${key}`, entityId: Number(req.params.id) }); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/catalogs/repository.js b/backend/src/modules/catalogs/repository.js new file mode 100644 index 0000000..c355e0f --- /dev/null +++ b/backend/src/modules/catalogs/repository.js @@ -0,0 +1,47 @@ +import { pool } from '../../config/db.js'; + +// Whitelist de catalogos editables — nunca aceptar el nombre de tabla desde el cliente. +export const CATALOGS = { + 'project-types': { table: 'project_types', fields: ['name'], order: 'name' }, + priorities: { table: 'priorities', fields: ['name', 'level', 'color'], order: 'level' }, + 'project-statuses': { + table: 'project_statuses', + fields: ['name', 'sort_order', 'color', 'is_closed'], + order: 'sort_order', + }, + 'activity-statuses': { + table: 'activity_statuses', + fields: ['name', 'sort_order', 'color', 'is_closed'], + order: 'sort_order', + }, +}; + +export async function findAll(catalogKey) { + const cfg = CATALOGS[catalogKey]; + const [rows] = await pool.query(`SELECT * FROM ${cfg.table} ORDER BY ${cfg.order}`); + return rows; +} + +export async function create(catalogKey, payload) { + const cfg = CATALOGS[catalogKey]; + const cols = cfg.fields.filter((f) => payload[f] !== undefined); + const placeholders = cols.map(() => '?').join(', '); + const [result] = await pool.query( + `INSERT INTO ${cfg.table} (${cols.join(', ')}) VALUES (${placeholders})`, + cols.map((c) => payload[c]) + ); + return { id: result.insertId, ...payload }; +} + +export async function update(catalogKey, id, payload) { + const cfg = CATALOGS[catalogKey]; + const cols = cfg.fields.filter((f) => payload[f] !== undefined); + const setClause = cols.map((c) => `${c} = ?`).join(', '); + await pool.query(`UPDATE ${cfg.table} SET ${setClause} WHERE id = ?`, [...cols.map((c) => payload[c]), id]); + return { id, ...payload }; +} + +export async function remove(catalogKey, id) { + const cfg = CATALOGS[catalogKey]; + await pool.query(`DELETE FROM ${cfg.table} WHERE id = ?`, [id]); +} diff --git a/backend/src/modules/catalogs/routes.js b/backend/src/modules/catalogs/routes.js new file mode 100644 index 0000000..de359bf --- /dev/null +++ b/backend/src/modules/catalogs/routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_CATALOGS } from '../../middleware/auth.js'; +import { getCatalog, postCatalog, putCatalog, deleteCatalog } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/:catalog', getCatalog); +router.post('/:catalog', requireRole(...CAN_MANAGE_CATALOGS), postCatalog); +router.put('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), putCatalog); +router.delete('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), deleteCatalog); + +export default router; diff --git a/backend/src/modules/comments/controller.js b/backend/src/modules/comments/controller.js new file mode 100644 index 0000000..b1ca3a4 --- /dev/null +++ b/backend/src/modules/comments/controller.js @@ -0,0 +1,24 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { ROLES } from '../../middleware/auth.js'; +import * as repo from './repository.js'; + +const VALID_ENTITY_TYPES = new Set(['project', 'activity']); + +export const getComments = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.query; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios'); + ok(res, await repo.findByEntity(entityType, Number(entityId))); +}); + +export const postComment = asyncHandler(async (req, res) => { + const { entityType, entityId, message } = req.body || {}; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId || !message) { + throw new HttpError(400, 'entityType, entityId y message son obligatorios'); + } + if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura'); + const comment = await repo.create({ entityType, entityId, userId: req.currentUser.id, message }); + await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } }); + ok(res, comment, 201); +}); diff --git a/backend/src/modules/comments/repository.js b/backend/src/modules/comments/repository.js new file mode 100644 index 0000000..8db1a22 --- /dev/null +++ b/backend/src/modules/comments/repository.js @@ -0,0 +1,24 @@ +import { pool } from '../../config/db.js'; + +export async function findByEntity(entityType, entityId) { + 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 + 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`, + [entityType, entityId] + ); + return rows; +} + +export async function create({ entityType, entityId, userId, message }) { + const [result] = await pool.query( + 'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)', + [entityType, entityId, userId, message] + ); + 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 + FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`, + [result.insertId] + ); + return rows[0]; +} diff --git a/backend/src/modules/comments/routes.js b/backend/src/modules/comments/routes.js new file mode 100644 index 0000000..456eff2 --- /dev/null +++ b/backend/src/modules/comments/routes.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getComments, postComment } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getComments); +router.post('/', postComment); + +export default router; diff --git a/backend/src/modules/dashboard/controller.js b/backend/src/modules/dashboard/controller.js new file mode 100644 index 0000000..f24dceb --- /dev/null +++ b/backend/src/modules/dashboard/controller.js @@ -0,0 +1,35 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import * as repo from './repository.js'; +import { listByProject } from '../activities/service.js'; +import { dueStatus } from '../../utils/dates.js'; + +export const getSummary = asyncHandler(async (req, res) => { + const [kpis, myActivities, byStatus] = await Promise.all([ + repo.summary(), + repo.myPendingActivities(req.currentUser.id), + repo.projectsByStatus(), + ]); + ok(res, { + kpis, + myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })), + projectsByStatus: byStatus, + }); +}); + +export const getCalendar = asyncHandler(async (req, res) => { + const month = req.query.month; // formato YYYY-MM + if (!month || !/^\d{4}-\d{2}$/.test(month)) return fail(res, 400, 'Parametro month invalido (YYYY-MM)'); + const start = `${month}-01`; + const [year, m] = month.split('-').map(Number); + const end = new Date(year, m, 0).toISOString().slice(0, 10); + const items = await repo.calendarActivities(start, end); + ok(res, items.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date, a.status_is_closed) }))); +}); + +export const getGantt = asyncHandler(async (req, res) => { + const activities = await listByProject(Number(req.params.id)); + ok(res, activities.map((a) => ({ + id: a.id, code: a.code, name: a.name, startDate: a.start_date, dueDate: a.due_date, + progressPct: a.progress_pct, isMilestone: !!a.is_milestone, responsibleName: a.responsible_name, + }))); +}); diff --git a/backend/src/modules/dashboard/repository.js b/backend/src/modules/dashboard/repository.js new file mode 100644 index 0000000..9af92e3 --- /dev/null +++ b/backend/src/modules/dashboard/repository.js @@ -0,0 +1,72 @@ +import { pool } from '../../config/db.js'; + +export async function summary() { + const [[activeProjects]] = await pool.query( + `SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0` + ); + const [[overdueProjects]] = await pool.query( + `SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id + WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE()` + ); + const [[dueSoonProjects]] = await pool.query( + `SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id + WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL + AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)` + ); + const [[pendingActivities]] = await pool.query( + `SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id WHERE st.is_closed = 0` + ); + const [[overdueActivities]] = await pool.query( + `SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id + WHERE st.is_closed = 0 AND a.due_date IS NOT NULL AND a.due_date < CURDATE()` + ); + const [[todayActivities]] = await pool.query( + `SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id + WHERE st.is_closed = 0 AND a.due_date = CURDATE()` + ); + const [[avgProgress]] = await pool.query( + `SELECT AVG(progress_pct) AS avg_pct FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0` + ); + + return { + activeProjects: activeProjects.c, + overdueProjects: overdueProjects.c, + dueSoonProjects: dueSoonProjects.c, + pendingActivities: pendingActivities.c, + overdueActivities: overdueActivities.c, + todayActivities: todayActivities.c, + averageProgress: avgProgress.avg_pct === null ? 0 : Math.round(Number(avgProgress.avg_pct) * 100) / 100, + }; +} + +export async function myPendingActivities(userId) { + const [rows] = await pool.query( + `SELECT a.id, a.code, a.name, a.due_date, a.progress_pct, p.id AS project_id, p.name AS project_name + FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id + WHERE st.is_closed = 0 AND a.responsible_user_id = ? + ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`, + [userId] + ); + return rows; +} + +export async function calendarActivities(monthStart, monthEnd) { + const [rows] = await pool.query( + `SELECT a.id, a.code, a.name, a.due_date, a.is_milestone, a.progress_pct, + st.is_closed AS status_is_closed, p.id AS project_id, p.name AS project_name + FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id + WHERE a.due_date BETWEEN ? AND ? + ORDER BY a.due_date`, + [monthStart, monthEnd] + ); + return rows; +} + +export async function projectsByStatus() { + const [rows] = await pool.query( + `SELECT ps.name, ps.color, COUNT(p.id) AS total + FROM project_statuses ps LEFT JOIN projects p ON p.status_id = ps.id + GROUP BY ps.id ORDER BY ps.sort_order` + ); + return rows; +} diff --git a/backend/src/modules/dashboard/routes.js b/backend/src/modules/dashboard/routes.js new file mode 100644 index 0000000..7ab123d --- /dev/null +++ b/backend/src/modules/dashboard/routes.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getSummary, getCalendar } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/summary', getSummary); +router.get('/calendar', getCalendar); + +export default router; diff --git a/backend/src/modules/departments/controller.js b/backend/src/modules/departments/controller.js new file mode 100644 index 0000000..6402996 --- /dev/null +++ b/backend/src/modules/departments/controller.js @@ -0,0 +1,17 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getDepartments = asyncHandler(async (req, res) => ok(res, await service.listTree())); + +export const postDepartment = asyncHandler(async (req, res) => { + ok(res, await service.createDepartment(req.body, req.currentUser.id), 201); +}); + +export const putDepartment = asyncHandler(async (req, res) => { + ok(res, await service.updateDepartment(Number(req.params.id), req.body, req.currentUser.id)); +}); + +export const deleteDepartment = asyncHandler(async (req, res) => { + await service.deleteDepartment(Number(req.params.id), req.currentUser.id); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/departments/repository.js b/backend/src/modules/departments/repository.js new file mode 100644 index 0000000..00d940b --- /dev/null +++ b/backend/src/modules/departments/repository.js @@ -0,0 +1,33 @@ +import { pool } from '../../config/db.js'; + +export async function findAllFlat() { + const [rows] = await pool.query('SELECT id, name, parent_id FROM departments ORDER BY name'); + return rows; +} + +export async function create({ name, parentId }) { + const [result] = await pool.query('INSERT INTO departments (name, parent_id) VALUES (?, ?)', [ + name, + parentId || null, + ]); + return { id: result.insertId, name, parent_id: parentId || null }; +} + +export async function update(id, { name, parentId }) { + await pool.query('UPDATE departments SET name = ?, parent_id = ? WHERE id = ?', [name, parentId || null, id]); + return { id, name, parent_id: parentId || null }; +} + +export async function remove(id) { + await pool.query('DELETE FROM departments WHERE id = ?', [id]); +} + +export async function hasChildren(id) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM departments WHERE parent_id = ?', [id]); + return rows[0].c > 0; +} + +export async function hasUsers(id) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM users WHERE department_id = ?', [id]); + return rows[0].c > 0; +} diff --git a/backend/src/modules/departments/routes.js b/backend/src/modules/departments/routes.js new file mode 100644 index 0000000..7d864e6 --- /dev/null +++ b/backend/src/modules/departments/routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js'; +import { getDepartments, postDepartment, putDepartment, deleteDepartment } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getDepartments); +router.post('/', requireRole(...CAN_MANAGE_USERS), postDepartment); +router.put('/:id', requireRole(...CAN_MANAGE_USERS), putDepartment); +router.delete('/:id', requireRole(...CAN_MANAGE_USERS), deleteDepartment); + +export default router; diff --git a/backend/src/modules/departments/service.js b/backend/src/modules/departments/service.js new file mode 100644 index 0000000..d3bf17e --- /dev/null +++ b/backend/src/modules/departments/service.js @@ -0,0 +1,43 @@ +import * as repo from './repository.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +function toTree(flat) { + const byId = new Map(flat.map((d) => [d.id, { ...d, children: [] }])); + const roots = []; + for (const dept of byId.values()) { + if (dept.parent_id && byId.has(dept.parent_id)) { + byId.get(dept.parent_id).children.push(dept); + } else { + roots.push(dept); + } + } + return roots; +} + +export async function listTree() { + const flat = await repo.findAllFlat(); + return toTree(flat); +} + +export async function createDepartment(payload, actorId) { + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const dept = await repo.create(payload); + await logAudit({ userId: actorId, action: 'create', entityType: 'department', entityId: dept.id }); + return dept; +} + +export async function updateDepartment(id, payload, actorId) { + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + if (Number(payload.parentId) === Number(id)) throw new HttpError(400, 'Un departamento no puede ser su propio padre'); + const dept = await repo.update(id, payload); + await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id }); + return dept; +} + +export async function deleteDepartment(id, actorId) { + if (await repo.hasChildren(id)) throw new HttpError(400, 'Tiene sub-departamentos; reasignalos antes de eliminar'); + if (await repo.hasUsers(id)) throw new HttpError(400, 'Tiene usuarios asignados; reasignalos antes de eliminar'); + await repo.remove(id); + await logAudit({ userId: actorId, action: 'delete', entityType: 'department', entityId: id }); +} diff --git a/backend/src/modules/notifications/controller.js b/backend/src/modules/notifications/controller.js new file mode 100644 index 0000000..9ef827f --- /dev/null +++ b/backend/src/modules/notifications/controller.js @@ -0,0 +1,20 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getNotifications = asyncHandler(async (req, res) => { + const [items, unread] = await Promise.all([ + service.listMine(req.currentUser.id), + service.countUnread(req.currentUser.id), + ]); + ok(res, { items, unread }); +}); + +export const patchNotificationRead = asyncHandler(async (req, res) => { + await service.markRead(Number(req.params.id), req.currentUser.id); + ok(res, { read: true }); +}); + +export const patchAllRead = asyncHandler(async (req, res) => { + await service.markAllRead(req.currentUser.id); + ok(res, { read: true }); +}); diff --git a/backend/src/modules/notifications/repository.js b/backend/src/modules/notifications/repository.js new file mode 100644 index 0000000..9850dcf --- /dev/null +++ b/backend/src/modules/notifications/repository.js @@ -0,0 +1,31 @@ +import { pool } from '../../config/db.js'; + +export async function findByUser(userId) { + const [rows] = await pool.query( + `SELECT id, type, message, entity_type, entity_id, is_read, created_at + FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`, + [userId] + ); + return rows; +} + +export async function countUnread(userId) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM notifications WHERE user_id = ? AND is_read = 0', [userId]); + return rows[0].c; +} + +export async function create({ userId, type, message, entityType, entityId }) { + const [result] = await pool.query( + 'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)', + [userId, type, message, entityType || null, entityId || null] + ); + return result.insertId; +} + +export async function markRead(id, userId) { + await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?', [id, userId]); +} + +export async function markAllRead(userId) { + await pool.query('UPDATE notifications SET is_read = 1 WHERE user_id = ?', [userId]); +} diff --git a/backend/src/modules/notifications/routes.js b/backend/src/modules/notifications/routes.js new file mode 100644 index 0000000..2fb459d --- /dev/null +++ b/backend/src/modules/notifications/routes.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getNotifications, patchNotificationRead, patchAllRead } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getNotifications); +router.patch('/read-all', patchAllRead); +router.patch('/:id/read', patchNotificationRead); + +export default router; diff --git a/backend/src/modules/notifications/service.js b/backend/src/modules/notifications/service.js new file mode 100644 index 0000000..d8fc7d1 --- /dev/null +++ b/backend/src/modules/notifications/service.js @@ -0,0 +1,7 @@ +import * as repo from './repository.js'; + +export const listMine = (userId) => repo.findByUser(userId); +export const countUnread = (userId) => repo.countUnread(userId); +export const createNotification = (payload) => repo.create(payload); +export const markRead = (id, userId) => repo.markRead(id, userId); +export const markAllRead = (userId) => repo.markAllRead(userId); diff --git a/backend/src/modules/projects/controller.js b/backend/src/modules/projects/controller.js new file mode 100644 index 0000000..2a6f42e --- /dev/null +++ b/backend/src/modules/projects/controller.js @@ -0,0 +1,29 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getProjects = asyncHandler(async (req, res) => { + const { statusId, priorityId, ownerUserId } = req.query; + ok(res, await service.listProjects({ statusId, priorityId, ownerUserId })); +}); + +export const getProject = asyncHandler(async (req, res) => { + ok(res, await service.getProject(Number(req.params.id))); +}); + +export const postProject = asyncHandler(async (req, res) => { + ok(res, await service.createProject(req.body, req.currentUser), 201); +}); + +export const putProject = asyncHandler(async (req, res) => { + ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser)); +}); + +export const postMember = asyncHandler(async (req, res) => { + const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser); + ok(res, members, 201); +}); + +export const deleteMember = asyncHandler(async (req, res) => { + const members = await service.removeMember(Number(req.params.id), Number(req.params.userId), req.currentUser); + ok(res, members); +}); diff --git a/backend/src/modules/projects/repository.js b/backend/src/modules/projects/repository.js new file mode 100644 index 0000000..bd0e8aa --- /dev/null +++ b/backend/src/modules/projects/repository.js @@ -0,0 +1,119 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = ` + SELECT p.id, p.code, p.name, p.description, p.objective, p.project_type_id, pt.name AS project_type_name, + p.priority_id, pr.name AS priority_name, pr.color AS priority_color, + p.status_id, ps.name AS status_name, ps.color AS status_color, ps.is_closed AS status_is_closed, + p.owner_user_id, u.name AS owner_name, + p.requesting_department_id, rd.name AS requesting_department_name, + p.responsible_department_id, sd.name AS responsible_department_name, + p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct, + p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at + FROM projects p + JOIN priorities pr ON pr.id = p.priority_id + JOIN project_statuses ps ON ps.id = p.status_id + JOIN users u ON u.id = p.owner_user_id + LEFT JOIN project_types pt ON pt.id = p.project_type_id + LEFT JOIN departments rd ON rd.id = p.requesting_department_id + LEFT JOIN departments sd ON sd.id = p.responsible_department_id +`; + +export async function findAll({ statusId, priorityId, ownerUserId } = {}) { + const clauses = []; + const params = []; + if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); } + if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); } + if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query(`${LIST_SELECT} WHERE p.id = ?`, [id]); + return rows[0] || null; +} + +export async function create(data) { + const [result] = await pool.query( + `INSERT INTO projects + (code, name, description, objective, project_type_id, priority_id, status_id, owner_user_id, + requesting_department_id, responsible_department_id, start_date, estimated_end_date, + risk_level, notes, created_by) + VALUES ('PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.name, data.description || null, data.objective || null, data.projectTypeId || null, + data.priorityId, data.statusId, data.ownerUserId, + data.requestingDepartmentId || null, data.responsibleDepartmentId || null, + data.startDate || null, data.estimatedEndDate || null, + data.riskLevel || 'low', data.notes || null, data.createdBy, + ] + ); + const id = result.insertId; + const code = `PRY-${String(id).padStart(4, '0')}`; + await pool.query('UPDATE projects SET code = ? WHERE id = ?', [code, id]); + // El creador queda como miembro por defecto. + await pool.query('INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', [ + id, data.ownerUserId, 'owner', + ]); + return findById(id); +} + +export async function update(id, data) { + await pool.query( + `UPDATE projects SET + name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?, + owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?, + start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ? + WHERE id = ?`, + [ + data.name, data.description || null, data.objective || null, data.projectTypeId || null, + data.priorityId, data.statusId, data.ownerUserId, + data.requestingDepartmentId || null, data.responsibleDepartmentId || null, + data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null, + data.riskLevel || 'low', data.notes || null, id, + ] + ); + return findById(id); +} + +export async function updateProgress(id, progressPct) { + await pool.query('UPDATE projects SET progress_pct = ? WHERE id = ?', [progressPct, id]); +} + +export async function listMembers(projectId) { + const [rows] = await pool.query( + `SELECT pm.user_id, u.name, u.email, pm.role_in_project, pm.added_at + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id = ? ORDER BY u.name`, + [projectId] + ); + return rows; +} + +export async function isMember(projectId, userId) { + const [rows] = await pool.query('SELECT 1 FROM project_members WHERE project_id = ? AND user_id = ?', [ + projectId, userId, + ]); + return rows.length > 0; +} + +export async function addMember(projectId, userId, roleInProject) { + await pool.query( + 'INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', + [projectId, userId, roleInProject || null] + ); + return listMembers(projectId); +} + +export async function removeMember(projectId, userId) { + await pool.query('DELETE FROM project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]); + return listMembers(projectId); +} + +export async function averageActivityProgress(projectId) { + const [rows] = await pool.query('SELECT AVG(progress_pct) AS avg_pct FROM activities WHERE project_id = ?', [ + projectId, + ]); + return rows[0].avg_pct === null ? 0 : Number(rows[0].avg_pct); +} diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js new file mode 100644 index 0000000..5ca0906 --- /dev/null +++ b/backend/src/modules/projects/routes.js @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getProjects, getProject, postProject, putProject, postMember, deleteMember } from './controller.js'; +import { getGantt } from '../dashboard/controller.js'; +import { getActivitiesByProject, postActivity } from '../activities/controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getProjects); +router.post('/', postProject); +router.get('/:id', getProject); +router.put('/:id', putProject); +router.get('/:id/gantt', getGantt); +router.post('/:id/members', postMember); +router.delete('/:id/members/:userId', deleteMember); +router.get('/:projectId/activities', getActivitiesByProject); +router.post('/:projectId/activities', postActivity); + +export default router; diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js new file mode 100644 index 0000000..77c9478 --- /dev/null +++ b/backend/src/modules/projects/service.js @@ -0,0 +1,73 @@ +import * as repo from './repository.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { ROLES } from '../../middleware/auth.js'; + +// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro. +export async function canWriteProject(user, project) { + if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true; + if (user.roleCode !== ROLES.PROJECT_LEAD) return false; + if (project.owner_user_id === user.id) return true; + return repo.isMember(project.id, user.id); +} + +export const listProjects = (filters) => repo.findAll(filters); + +export async function getProject(id) { + const project = await repo.findById(id); + if (!project) throw new HttpError(404, 'Proyecto no encontrado'); + const members = await repo.listMembers(id); + return { ...project, members }; +} + +export async function createProject(payload, currentUser) { + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + if (![ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) { + throw new HttpError(403, 'No tienes permisos para crear proyectos'); + } + const project = await repo.create({ + ...payload, + ownerUserId: payload.ownerUserId || currentUser.id, + createdBy: currentUser.id, + }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'project', entityId: project.id }); + return project; +} + +export async function updateProject(id, payload, currentUser) { + const project = await repo.findById(id); + if (!project) throw new HttpError(404, 'Proyecto no encontrado'); + if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto'); + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id }); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id }); + return updated; +} + +// Recalcula el avance del proyecto como promedio del avance de sus actividades. +export async function recalcProjectProgress(projectId) { + const avg = await repo.averageActivityProgress(projectId); + await repo.updateProgress(projectId, Math.round(avg * 100) / 100); +} + +export async function addMember(projectId, userId, roleInProject, currentUser) { + const project = await repo.findById(projectId); + if (!project) throw new HttpError(404, 'Proyecto no encontrado'); + if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto'); + const members = await repo.addMember(projectId, userId, roleInProject); + await logAudit({ userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId, detail: { userId } }); + return members; +} + +export async function removeMember(projectId, userId, currentUser) { + const project = await repo.findById(projectId); + if (!project) throw new HttpError(404, 'Proyecto no encontrado'); + if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto'); + const members = await repo.removeMember(projectId, userId); + await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } }); + return members; +} diff --git a/backend/src/modules/subtasks/controller.js b/backend/src/modules/subtasks/controller.js new file mode 100644 index 0000000..185e986 --- /dev/null +++ b/backend/src/modules/subtasks/controller.js @@ -0,0 +1,23 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getSubtasks = asyncHandler(async (req, res) => { + ok(res, await service.listByActivity(Number(req.params.activityId))); +}); + +export const postSubtask = asyncHandler(async (req, res) => { + ok(res, await service.createSubtask(Number(req.params.activityId), req.body, req.currentUser), 201); +}); + +export const putSubtask = asyncHandler(async (req, res) => { + ok(res, await service.updateSubtask(Number(req.params.id), req.body, req.currentUser)); +}); + +export const patchSubtaskDone = asyncHandler(async (req, res) => { + ok(res, await service.setSubtaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser)); +}); + +export const deleteSubtask = asyncHandler(async (req, res) => { + await service.removeSubtask(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/subtasks/repository.js b/backend/src/modules/subtasks/repository.js new file mode 100644 index 0000000..67e1337 --- /dev/null +++ b/backend/src/modules/subtasks/repository.js @@ -0,0 +1,49 @@ +import { pool } from '../../config/db.js'; + +export async function findByActivity(activityId) { + const [rows] = await pool.query( + `SELECT s.id, s.activity_id, s.name, s.responsible_user_id, u.name AS responsible_name, + s.is_done, s.due_date, s.created_at + FROM subtasks s LEFT JOIN users u ON u.id = s.responsible_user_id + WHERE s.activity_id = ? ORDER BY s.created_at`, + [activityId] + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query('SELECT * FROM subtasks WHERE id = ?', [id]); + return rows[0] || null; +} + +export async function create(activityId, { name, responsibleUserId, dueDate }) { + const [result] = await pool.query( + 'INSERT INTO subtasks (activity_id, name, responsible_user_id, due_date) VALUES (?, ?, ?, ?)', + [activityId, name, responsibleUserId || null, dueDate || null] + ); + return findById(result.insertId); +} + +export async function update(id, { name, responsibleUserId, dueDate }) { + await pool.query('UPDATE subtasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [ + name, responsibleUserId || null, dueDate || null, id, + ]); + return findById(id); +} + +export async function setDone(id, isDone) { + await pool.query('UPDATE subtasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM subtasks WHERE id = ?', [id]); +} + +export async function countsByActivity(activityId) { + const [rows] = await pool.query( + 'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM subtasks WHERE activity_id = ?', + [activityId] + ); + return { total: rows[0].total, done: Number(rows[0].done) || 0 }; +} diff --git a/backend/src/modules/subtasks/routes.js b/backend/src/modules/subtasks/routes.js new file mode 100644 index 0000000..8820d26 --- /dev/null +++ b/backend/src/modules/subtasks/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getSubtasks, postSubtask, putSubtask, patchSubtaskDone, deleteSubtask } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/activities/:activityId/subtasks', getSubtasks); +router.post('/activities/:activityId/subtasks', postSubtask); +router.put('/subtasks/:id', putSubtask); +router.patch('/subtasks/:id/done', patchSubtaskDone); +router.delete('/subtasks/:id', deleteSubtask); + +export default router; diff --git a/backend/src/modules/subtasks/service.js b/backend/src/modules/subtasks/service.js new file mode 100644 index 0000000..1b49898 --- /dev/null +++ b/backend/src/modules/subtasks/service.js @@ -0,0 +1,59 @@ +import * as repo from './repository.js'; +import * as activitiesRepo from '../activities/repository.js'; +import * as projectsRepo from '../projects/repository.js'; +import { canWriteActivity, recalcFromSubtasks } from '../activities/service.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +async function requireActivityAccess(activityId, currentUser) { + const activity = await activitiesRepo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await projectsRepo.findById(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) { + throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + } + return activity; +} + +export const listByActivity = (activityId) => repo.findByActivity(activityId); + +export async function createSubtask(activityId, payload, currentUser) { + await requireActivityAccess(activityId, currentUser); + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const subtask = await repo.create(activityId, payload); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'subtask', entityId: subtask.id }); + const counts = await repo.countsByActivity(activityId); + await recalcFromSubtasks(activityId, counts.done, counts.total); + return subtask; +} + +export async function updateSubtask(id, payload, currentUser) { + const subtask = await repo.findById(id); + if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); + await requireActivityAccess(subtask.activity_id, currentUser); + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const updated = await repo.update(id, payload); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'subtask', entityId: id }); + return updated; +} + +export async function setSubtaskDone(id, isDone, currentUser) { + const subtask = await repo.findById(id); + if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); + await requireActivityAccess(subtask.activity_id, currentUser); + const updated = await repo.setDone(id, isDone); + await logAudit({ userId: currentUser.id, action: isDone ? 'complete' : 'reopen', entityType: 'subtask', entityId: id }); + const counts = await repo.countsByActivity(subtask.activity_id); + await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total); + return updated; +} + +export async function removeSubtask(id, currentUser) { + const subtask = await repo.findById(id); + if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); + await requireActivityAccess(subtask.activity_id, currentUser); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'subtask', entityId: id }); + const counts = await repo.countsByActivity(subtask.activity_id); + await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total); +} diff --git a/backend/src/modules/users/controller.js b/backend/src/modules/users/controller.js new file mode 100644 index 0000000..50332b0 --- /dev/null +++ b/backend/src/modules/users/controller.js @@ -0,0 +1,20 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getUsers = asyncHandler(async (req, res) => ok(res, await service.listUsers())); +export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles())); + +export const postUser = asyncHandler(async (req, res) => { + const user = await service.createUser(req.body, req.currentUser.id); + ok(res, user, 201); +}); + +export const putUser = asyncHandler(async (req, res) => { + const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser.id); + ok(res, user); +}); + +export const patchUserActive = asyncHandler(async (req, res) => { + const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id); + ok(res, user); +}); diff --git a/backend/src/modules/users/repository.js b/backend/src/modules/users/repository.js new file mode 100644 index 0000000..29cdfbf --- /dev/null +++ b/backend/src/modules/users/repository.js @@ -0,0 +1,54 @@ +import { pool } from '../../config/db.js'; + +export async function findAll() { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name, + u.department_id, d.name AS department_name, u.position, u.is_active, u.created_at + FROM users u + JOIN roles r ON r.id = u.role_id + LEFT JOIN departments d ON d.id = u.department_id + ORDER BY u.name` + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active + FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, + [id] + ); + return rows[0] || null; +} + +export async function findByEmail(email) { + const [rows] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); + return rows[0] || null; +} + +export async function create({ name, email, passwordHash, roleId, departmentId, position }) { + const [result] = await pool.query( + `INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active) + VALUES (?, ?, ?, ?, ?, ?, 1)`, + [name, email, passwordHash, roleId, departmentId || null, position || null] + ); + return findById(result.insertId); +} + +export async function update(id, { name, roleId, departmentId, position }) { + await pool.query( + `UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ? WHERE id = ?`, + [name, roleId, departmentId || null, position || null, id] + ); + return findById(id); +} + +export async function setActive(id, isActive) { + await pool.query('UPDATE users SET is_active = ? WHERE id = ?', [isActive ? 1 : 0, id]); + return findById(id); +} + +export async function listRoles() { + const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id'); + return rows; +} diff --git a/backend/src/modules/users/routes.js b/backend/src/modules/users/routes.js new file mode 100644 index 0000000..300e3d1 --- /dev/null +++ b/backend/src/modules/users/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js'; +import { getUsers, getRoles, postUser, putUser, patchUserActive } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getUsers); +router.get('/roles', getRoles); +router.post('/', requireRole(...CAN_MANAGE_USERS), postUser); +router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser); +router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive); + +export default router; diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js new file mode 100644 index 0000000..2354130 --- /dev/null +++ b/backend/src/modules/users/service.js @@ -0,0 +1,41 @@ +import bcrypt from 'bcryptjs'; +import * as repo from './repository.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +export const listUsers = () => repo.findAll(); +export const listRoles = () => repo.listRoles(); + +export async function createUser(payload, actorId) { + if (!payload.name || !payload.email || !payload.password || !payload.roleId) { + throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios'); + } + const existing = await repo.findByEmail(payload.email); + if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email'); + + const passwordHash = await bcrypt.hash(payload.password, 12); + const user = await repo.create({ ...payload, passwordHash }); + await logAudit({ userId: actorId, action: 'create', entityType: 'user', entityId: user.id }); + return user; +} + +export async function updateUser(id, payload, actorId) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Usuario no encontrado'); + const user = await repo.update(id, payload); + await logAudit({ userId: actorId, action: 'update', entityType: 'user', entityId: id }); + return user; +} + +export async function setUserActive(id, isActive, actorId) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Usuario no encontrado'); + const user = await repo.setActive(id, isActive); + await logAudit({ + userId: actorId, + action: isActive ? 'activate' : 'deactivate', + entityType: 'user', + entityId: id, + }); + return user; +} diff --git a/backend/src/scripts/migrate-db.js b/backend/src/scripts/migrate-db.js new file mode 100644 index 0000000..98880a3 --- /dev/null +++ b/backend/src/scripts/migrate-db.js @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; +import { env } from '../config/env.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const migrationsDir = path.resolve(__dirname, '../../database/migrations'); + +async function run() { + const connection = await mysql.createConnection({ + host: env.db.host, + port: env.db.port, + database: env.db.name, + user: env.db.user, + password: env.db.password, + multipleStatements: true, + }); + + const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8'); + console.log(`[migrate] aplicando ${file}`); + await connection.query(sql); + } + + console.log(`[migrate] listo — ${files.length} archivo(s) aplicados`); + await connection.end(); +} + +run().catch((err) => { + console.error('[migrate] error:', err.message); + process.exit(1); +}); diff --git a/backend/src/scripts/seed-db.js b/backend/src/scripts/seed-db.js new file mode 100644 index 0000000..ca97f81 --- /dev/null +++ b/backend/src/scripts/seed-db.js @@ -0,0 +1,108 @@ +import bcrypt from 'bcryptjs'; +import mysql from 'mysql2/promise'; +import { env } from '../config/env.js'; + +async function run() { + const db = await mysql.createConnection({ + host: env.db.host, + port: env.db.port, + database: env.db.name, + user: env.db.user, + password: env.db.password, + }); + + // Roles fijos del MVP. + const roles = [ + ['admin', 'Administrador'], + ['it_manager', 'Jefe de TI'], + ['project_lead', 'Coordinador / Lider de proyecto'], + ['collaborator', 'Colaborador'], + ['viewer', 'Consultor (solo lectura)'], + ]; + for (const [code, name] of roles) { + await db.query('INSERT IGNORE INTO roles (code, name) VALUES (?, ?)', [code, name]); + } + + const priorities = [ + ['Baja', 1, 'muted'], + ['Media', 2, 'accent'], + ['Alta', 3, 'warning'], + ['Critica', 4, 'danger'], + ]; + for (const [name, level, color] of priorities) { + await db.query('INSERT IGNORE INTO priorities (name, level, color) VALUES (?, ?, ?)', [name, level, color]); + } + + const projectStatuses = [ + ['Planificado', 1, 'muted', 0], + ['En preparacion', 2, 'accent', 0], + ['En ejecucion', 3, 'accent', 0], + ['En pausa', 4, 'warning', 0], + ['Bloqueado', 5, 'danger', 0], + ['Completado', 6, 'success', 1], + ['Cancelado', 7, 'muted', 1], + ]; + for (const [name, sort, color, closed] of projectStatuses) { + await db.query( + 'INSERT IGNORE INTO project_statuses (name, sort_order, color, is_closed) VALUES (?, ?, ?, ?)', + [name, sort, color, closed] + ); + } + + const activityStatuses = [ + ['Pendiente', 1, 'muted', 0], + ['En progreso', 2, 'accent', 0], + ['En revision', 3, 'accent', 0], + ['Bloqueada', 4, 'danger', 0], + ['En espera', 5, 'warning', 0], + ['Completada', 6, 'success', 1], + ['Cancelada', 7, 'muted', 1], + ]; + for (const [name, sort, color, closed] of activityStatuses) { + await db.query( + 'INSERT IGNORE INTO activity_statuses (name, sort_order, color, is_closed) VALUES (?, ?, ?, ?)', + [name, sort, color, closed] + ); + } + + const projectTypes = ['Infraestructura', 'Desarrollo de software', 'Soporte', 'Seguridad', 'Mejora interna']; + for (const name of projectTypes) { + await db.query('INSERT IGNORE INTO project_types (name) VALUES (?)', [name]); + } + + const departments = [ + ['TI', null], + ['Finanzas', null], + ['RRHH', null], + ['Comercial', null], + ]; + const deptIds = {}; + for (const [name] of departments) { + await db.query('INSERT IGNORE INTO departments (name, parent_id) VALUES (?, NULL)', [name]); + const [rows] = await db.query('SELECT id FROM departments WHERE name = ? AND parent_id IS NULL', [name]); + deptIds[name] = rows[0].id; + } + const subDepartments = ['Desarrollo', 'Infraestructura', 'Soporte', 'Seguridad']; + for (const name of subDepartments) { + await db.query('INSERT IGNORE INTO departments (name, parent_id) VALUES (?, ?)', [name, deptIds['TI']]); + } + + // Usuario admin de dev — convencion del grupo: clave = usuario, SOLO en entornos no productivos. + const [adminRole] = await db.query("SELECT id FROM roles WHERE code = 'admin'"); + const [tiDept] = await db.query("SELECT id FROM departments WHERE name = 'TI' AND parent_id IS NULL"); + const passwordHash = await bcrypt.hash('mgonzalez', 12); + await db.query( + `INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active) + VALUES (?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE name = VALUES(name)`, + ['Milton Gonzalez', 'mgonzalez@linkedcorp.net', passwordHash, adminRole[0].id, tiDept[0].id, 'Jefe de TI'] + ); + + console.log('[seed] listo. Usuario dev: mgonzalez@linkedcorp.net / clave: mgonzalez'); + await db.end(); +} + +run().catch((err) => { + console.error('[seed] error:', err.message); + process.exit(1); +}); diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..c0c38a8 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,6 @@ +import { app } from './app.js'; +import { env } from './config/env.js'; + +app.listen(env.port, () => { + console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); +}); diff --git a/backend/src/utils/audit.js b/backend/src/utils/audit.js new file mode 100644 index 0000000..0df1cee --- /dev/null +++ b/backend/src/utils/audit.js @@ -0,0 +1,8 @@ +import { pool } from '../config/db.js'; + +export async function logAudit({ userId, action, entityType, entityId, detail }) { + await pool.query( + `INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`, + [userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null] + ); +} diff --git a/backend/src/utils/code.js b/backend/src/utils/code.js new file mode 100644 index 0000000..a9b7fcd --- /dev/null +++ b/backend/src/utils/code.js @@ -0,0 +1,4 @@ +// Genera codigos legibles tipo PRY-0007 / ACT-0042 a partir del ultimo id insertado. +export function buildCode(prefix, id) { + return `${prefix}-${String(id).padStart(4, '0')}`; +} diff --git a/backend/src/utils/dates.js b/backend/src/utils/dates.js new file mode 100644 index 0000000..2238b7c --- /dev/null +++ b/backend/src/utils/dates.js @@ -0,0 +1,19 @@ +// Estado de vencimiento calculado al vuelo (no se guarda en BD). +// Reglas del SPEC: vence en >3 dias = normal, <=3 dias = advertencia, hoy = urgente, vencido = critico. +export function dueStatus(dueDate, isDone = false) { + if (!dueDate || isDone) return 'none'; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const due = new Date(dueDate); + due.setHours(0, 0, 0, 0); + const diffDays = Math.round((due - today) / 86400000); + + if (diffDays < 0) return 'overdue'; + if (diffDays === 0) return 'today'; + if (diffDays <= 3) return 'warning'; + return 'on_time'; +} + +export function todayMysql() { + return new Date().toISOString().slice(0, 10); +} diff --git a/backend/src/utils/http.js b/backend/src/utils/http.js new file mode 100644 index 0000000..a9dba10 --- /dev/null +++ b/backend/src/utils/http.js @@ -0,0 +1,18 @@ +export function ok(res, data = null, status = 200) { + return res.status(status).json({ success: true, data, error: null }); +} + +export function fail(res, status, message) { + return res.status(status).json({ success: false, data: null, error: message }); +} + +export class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +export function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} diff --git a/docs/SEGURIDAD-Y-CALIDAD.md b/docs/SEGURIDAD-Y-CALIDAD.md new file mode 100644 index 0000000..c0a5b2d --- /dev/null +++ b/docs/SEGURIDAD-Y-CALIDAD.md @@ -0,0 +1,109 @@ +# Seguridad y Calidad — project.linkedcorp.net + +> Copia ajustada de la plantilla del grupo (`project-md-templates/SEGURIDAD-Y-CALIDAD.md`). Léela +> antes de escribir código de una nueva funcionalidad y úsala como checklist antes de mergear. +> +> **Diferencia con la plantilla original**: la plantilla asume multi-empresa (`empresaId` por +> tenant). Este proyecto es de **una sola empresa** (single-company), así que el "aislamiento" que +> aplica aquí no es entre empresas sino entre **usuarios y su alcance por rol**: qué proyectos/ +> actividades puede ver o tocar cada rol (ver tabla de roles en `SPEC.md`). +> +> Regla de oro: **la seguridad primero; luego, cero duplicación; luego, facilidad de revisión y de +> extender.** + +--- + +## 1. Seguridad (NO negociable) + +### 1.1 Alcance por rol (equivalente al aislamiento multi-tenant) +- **NUNCA** confíes en un `userId`/`role` que venga en el **body/query**. El usuario y su rol salen + SIEMPRE del token (`req.currentUser`), nunca del body. +- Reglas de alcance por rol (ver `SPEC.md`): + - **Colaborador**: solo puede ver/actualizar actividades y subtareas donde es `responsible_user_id`, + y comentar en los proyectos donde es miembro. + - **Coordinador/Líder**: solo puede crear/editar proyectos donde es `owner_user_id` o miembro + (`project_members`), y las actividades de esos proyectos. + - **Jefe de TI** y **Administrador**: sin restricción de alcance. + - **Consultor**: solo lectura en todo, ningún endpoint de escritura le responde 2xx. +- Antes de `update`/`delete`/`get by id` de un recurso, verificar que el usuario tiene alcance sobre + ese recurso (por rol + pertenencia al proyecto) y responder **404** si no corresponde (no 403, + para no revelar existencia a quien no debería ni saber que existe). +- Los IDs relacionados que llegan en el body (`projectId`, `activityId`, `dependsOnActivityId`, etc.) + deben validarse: que existan y que el usuario tenga alcance sobre ellos antes de usarlos. + +### 1.2 Autenticación y autorización +- Toda ruta no pública pasa por middleware de auth (`backend/src/middleware/auth.js`). +- **Revalida en CADA request** que la cuenta siga activa (`users.is_active`). Un JWT válido por firma + NO basta: desactivar un usuario debe cortarle el acceso al instante, no cuando expire el token. +- La autorización del **frontend es solo UX** (ocultar botones/menús); el backend re-verifica rol y + alcance en cada endpoint. El usuario en `localStorage` es manipulable. + +### 1.3 Secretos +- Contraseñas: `bcrypt` (cost ≥ 12). Nunca en logs ni respuestas. +- `.env` fuera de git. El arranque del backend debe **abortar en producción** si `JWT_SECRET` o las + credenciales de BD son valores por defecto/vacíos. +- No exponer rutas absolutas del filesystem del servidor en respuestas de API. + +### 1.4 Entrada y datos +- **SQL siempre parametrizado** (placeholders `?`). Nunca concatenar entrada del usuario. Las + cláusulas `IN (...)` dinámicas se arman con placeholders, no con el valor. +- Nombres de columna/orden dinámicos SOLO desde una **whitelist** del servidor, jamás desde el query + string tal cual. +- Uploads (`attachments`): validar tamaño y **MIME por whitelist**. El nombre/ruta del archivo lo + genera el servidor (evita path traversal); nunca usar el nombre que manda el cliente como ruta. +- Fechas: validar coherencia (`due_date >= start_date`, etc.) en el servicio, no solo en el frontend. + +### 1.5 Errores, logs y límites +- Los **5xx** ocultan el detalle al cliente en producción. +- **Rate limiting** en `POST /api/auth/login`. +- Sirviendo la SPA: CSP (helmet) — `script-src 'self'` (sin inline), `style-src 'self' 'unsafe-inline'`, + `img-src 'self' data: blob:`, `object-src 'none'`. + +### 1.6 Frontend / caché +- El JWT en `localStorage` es un trade-off aceptado; mantener CSP y sanitización. +- Si en el futuro se agrega caché offline, purgarla en logout y en `401`. + +--- + +## 2. Cero duplicación (procesos, flujos y funciones) + +- Un helper vive en **un solo lugar**. Antes de escribir una función, buscar si ya existe. +- Helpers compartidos en `backend/src/utils/` (fechas, cálculo de `progress_pct`, cálculo de estado + de vencimiento 🟢/🟡/🔴) y `frontend/src/services`/`lib` (formateadores, `fmtDate`, `roleLabel`). +- El patrón CRUD repetido (listar/buscar/crear/editar/borrar) se abstrae en un hook reutilizable en + el frontend (`useAsync`/`useCrud`); loading/try-catch/ContentDialog de error, en un solo lugar. +- Una sola capa de API en el frontend (`src/services/api.js` con `api.get/post/put/delete`). + +--- + +## 3. Orden, revisión y facilidad de extender + +- Arquitectura backend por módulo: **routes → controller → service → repository → db**. Un módulo + nuevo replica esa forma (ver `projects`/`activities` como plantilla). +- **Un archivo, una responsabilidad.** Si un `service` crece mucho o mezcla temas, partirlo. +- Sin código muerto. +- Convención única de respuestas: `{ success, data, error }` en todos los endpoints; listados con + `{ items, total }` dentro de `data`. +- El cálculo de `progress_pct` (actividad y proyecto) vive en **un solo lugar** (`activities/service.js` + y `projects/service.js`), nunca duplicado en el frontend como fuente de verdad — el frontend solo + muestra el valor que devuelve la API. + +--- + +## 4. Checklist antes de mergear / desplegar + +Seguridad +- [ ] ¿El usuario/rol sale del token, no del body? +- [ ] ¿`update/delete/get-by-id` verifican alcance por rol (404 si no corresponde)? +- [ ] ¿La ruta tiene el middleware de auth/rol correcto? ¿Se revalida `is_active`? +- [ ] ¿SQL parametrizado (incl. `IN`)? ¿MIME/tamaño validados en adjuntos? +- [ ] ¿Secretos fuera de respuestas/logs? ¿5xx sin datos internos? + +Calidad +- [ ] ¿Reutilicé helpers existentes en vez de duplicar? +- [ ] ¿Borré el código muerto que este cambio deja atrás? +- [ ] ¿El archivo/módulo sigue el patrón y no se volvió inmanejable? + +Verificación +- [ ] Build/backend arrancan sin errores; smoke test del flujo tocado en dev. +- [ ] Migraciones idempotentes; respaldo antes de aplicarlas en producción (cuando exista deploy). diff --git a/docs/UI-UX-WINDOWS.html b/docs/UI-UX-WINDOWS.html new file mode 100644 index 0000000..e9f793a --- /dev/null +++ b/docs/UI-UX-WINDOWS.html @@ -0,0 +1,1265 @@ + + + + + +Identidad UI/UX — Windows (shell nombrado, muestra viva) + + + + +
+
+ + +
+ barra-titulo +
LinkedCorp Desktop
+
+ + + +
+
+ +
+ + + + +
+ col-derecha + + +
+ barra-comandos + Resumen +
+ + + +
+ +
+
+ + +
+ migas-pan + + ›Resumen +
+ + +
+ area-contenido + + +
+
+

Guía viva — identidad Windows

+

Esta ventana simula el shell de una app de escritorio moderna (estilo Fluent / Windows 11), pensada para mouse: hover en cada control, clic derecho en las filas del listado, y arrastra el divisor en “Maestro-detalle”. Activa “Nombres” (barra de comandos › ⋯) para ver el nombre de cada región/componente y pedir cambios por su nombre exacto.

+
+ +
+
+ +
+

Botones (Accent / Standard / Subtle / Danger / Hyperlink)

+
+ + + + + + + +
+
+ +
+
+

Colores (tokens)

+
+ Info + Éxito + Atención + Error +
+

Acento del sistema (Windows deja elegir uno):

+
+
+
+

Campos

+
+
+ Este campo es obligatorio.
+
+
+ +
+

Estados de carga

+
+
+
+
+
+
+
+ + +
+
+

Indicadores

+
+
Ventas del mes
$48,210
▲ 12.4%
+
Clientes activos
1,284
▲ 3.1%
+
Tickets abiertos
37
▼ 5.0%
+
+
+
+

Ventas (línea)

+

Distribución (dona)

+
+
+ + +
+
+

ListView — selección, orden, clic derecho

+

Clic en una fila selecciona; clic derecho abre el menú contextual; clic en el encabezado ordena la columna.

+
+
buscador
+
+
Todos
+
+
Todos
+
Activo
+
Pendiente
+
Inactivo
+
+
+ +
+
listview + + + + + + + + + +
CódigoClienteMontoEstado
+
+
+ Líneas por página: + + + +
+
+
+
+ + +
+
+

Maestro-detalle (Split View)

+

Patrón nativo de Windows (Correo, Configuración, Explorador): al seleccionar un ítem de la lista, el panel derecho se actualiza en el sitio, sin modal. Arrastra el divisor central para redimensionar.

+
panel-detalle +
+
+
+
+
+
+ + +
+
+

Controles de formulario

+
+
+
+
+
+
+
+
Selecciona…
+
+
+
+
+
+
+
+
Elegir fecha…
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+ + +
+
+

ContentDialog — REGLA DE ORO para decisiones

+

Nunca alert()/confirm()/MessageBox nativo. Toda decisión pasa por un ContentDialog propio, centrado, con acción primaria a la derecha.

+
+ + + + + + +
+
+
+

InfoBar — aviso no bloqueante embebido en el contenido

+
+
Sincronización activaLos cambios se guardan automáticamente cada 30s.
+
Licencia por vencerQuedan 5 días de tu plan actual.
+
+ +
+
+
+

Toast — solo eventos de fondo

+

Para procesos sin interacción (ej. "Copia de seguridad completada"). No sirve para pedir una decisión.

+ +
+
+

Teaching Tip — guía contextual

+

Coach mark anclado a un control, para onboarding de una función nueva.

+ +
+
+
+ + +
+
+

Barra de menú clásica (opcional, apps de productividad)

+ +

Se abre con clic; también responde a clic derecho en cualquier punto de esta tarjeta.

+
+ +
+
+

Menú contextual (clic derecho)

+
menucontextualClic derecho (o clic) aquí para ver el menú
+
+
+

Flyout de usuario

+

Anclado al avatar del panel de navegación (ábrelo desde la esquina inferior izquierda).

+ +
+
+
+ + +
+
+

Árbol jerárquico (estilo Explorador)

+
    +
    +
    + + +
    +
    +

    Configuración (patrón Expander de Windows 11)

    +
    +
    + +
    PersonalizaciónTema, acento e idioma
    + +
    +
    +
    + Tema + + +
    +
    + Idioma + + +
    +
    Acento del sistema
    +
    +
    +
    +
    + +
    NotificacionesControla los avisos de la aplicación
    + +
    +
    +
    + +
    +
    +
    +
    + +
    Cuenta y seguridadSesión, cierre y datos
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +

    Ventana de inicio de sesión

    +
    +
    +
    Iniciar sesión
    +
    +
    MG
    +
    +
    + + +
    +
    +
    +
    +
    +

    Ventana “Acerca de”

    +
    +
    +
    Acerca de
    +
    + +

    LinkedCorp Desktop

    +

    Versión 1.4.0 — © 2026 LinkedCorp

    + +
    +
    +
    +
    +
    +
    +

    Reporte / vista previa de impresión

    + +
    +
    +
    + + +
    +
    +

    Galería de íconos (Lucide, local)

    +

    Un solo set en toda la app; los íconos heredan el color del contexto (activo = acento). Alternativa 100% nativa: Segoe Fluent Icons (fuente del sistema Windows) manteniendo la misma nomenclatura de claves.

    +
    + +
    +
    +
    + +
    + + +
    + barra-estado + Listo +
    + + + v1.4.0 +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    +

    Milton González

    +

    miltongonzalez74@gmail.com

    +
    +
    + +
    +

    Nuevo: clic derecho

    +

    Ahora puedes hacer clic derecho sobre cualquier fila para ver acciones rápidas.

    +
    +
    + +
    + + + + + + diff --git a/docs/UI-UX-WINDOWS.md b/docs/UI-UX-WINDOWS.md new file mode 100644 index 0000000..dde6d0b --- /dev/null +++ b/docs/UI-UX-WINDOWS.md @@ -0,0 +1,273 @@ +# UI/UX — Identidad para aplicaciones de escritorio Windows (cómo construir pantallas) + +> 🖥️ **Muestra viva:** `UI-UX-WINDOWS.html` (en esta misma carpeta) — ábrela en el navegador para +> VER todos los elementos (ventana con barra de título propia, panel de navegación, comandos, +> listado con clic derecho, maestro-detalle, diálogos, flyouts, configuración). **Es lo primero que +> se le muestra al usuario** al arrancar un proyecto de escritorio Windows para que apruebe o pida +> ajustes de la identidad antes de construir. Usa dos JS **locales** junto al HTML (sin CDN, offline): +> `lucide.min.js` (íconos) y `chart.umd.js` (gráficas Chart.js) — cópialos junto al HTML si se mueve. + +> ⚠️ **Este documento es la referencia visual y de interacción** (look & feel, tokens, componentes, +> nomenclatura de regiones). **No asume una tecnología de implementación concreta**: sirve tanto para +> una app WinUI 3/.NET nativa como para una app Electron/Tauri, o cualquier otro stack de escritorio +> Windows. Cada proyecto decide su stack al copiar este documento; lo que **no cambia** es el +> comportamiento y la identidad descritos aquí — son los que hacen que la app "se sienta Windows". + +Estilo de referencia: **Fluent Design / Windows 11** (Mica, esquinas redondeadas, tipografía Segoe UI +Variable, paleta de acento del sistema). Pensado **primero para mouse** (hover, clic derecho, arrastre), +sin dejar de ser accesible por teclado. + +--- + +## 1. Identidad visual (tokens y tema) + +Colores como **variables CSS RGB** (o el equivalente del framework elegido: `DynamicResource` en +WPF/WinUI, CSS vars en Electron/Tauri), para poder escribir `rgb(var(--accent)/.2)` y variantes con +transparencia. Tema **oscuro por defecto** + claro vía `data-theme="light"`. + +| Token | Uso | Oscuro (ref.) | Claro (ref.) | +|---|---|---|---| +| `canvas` | fondo de ventana (equivalente a Mica) | `32 32 32` | `243 243 243` | +| `surface` | tarjetas, diálogos, flyouts | `43 43 43` | `255 255 255` | +| `panel` | panel de navegación, hover de filas | `51 51 51` | `249 249 249` | +| `field` | fondo de campos de entrada | `43 43 43` | `255 255 255` | +| `body` | texto principal | `255 255 255` | `32 32 32` | +| `muted` | texto secundario, labels, hints | `168 168 168` | `96 96 96` | +| `line` | bordes y divisores | `70 70 70` | `225 225 225` | +| `accent` | acción primaria — **el acento del sistema** | `0 120 212` | `0 103 192` | +| `success` / `warning` / `danger` | estados | `108 203 95` / `255 185 0` / `255 105 97` | `16 124 16` / `157 93 0` / `196 43 28` | + +**Acento del sistema (do & don't):** Windows deja al usuario elegir un color de acento en la +configuración del sistema; la app debe **heredarlo** cuando el framework lo expone (WinUI 3 lo hace +automático vía `AccentColor`), o dar un selector propio con la paleta estándar si no hay acceso al +acento del SO. Paleta de referencia (la que usa la muestra viva): `blue #0078D4` (por defecto), +`purple #8764B8`, `green #10893E`, `red #C42B1C`, `orange #CA5010`, `teal #009984`. + +**Tipografía:** **Segoe UI Variable** (Windows 11) como fuente del sistema — no requiere carga de +fuente web, ya está instalada en Windows: `font-family:'Segoe UI Variable Text','Segoe UI',system-ui,sans-serif`. +Los títulos grandes usan el corte *Display* de la misma familia (`Segoe UI Variable Display`). +Escala: `.72rem` metadatos/labels · `.8–.82rem` cuerpo/controles · `1.05–1.25rem` títulos de sección · +`1.6rem+` valores KPI/Display. Pesos: 600–700 títulos y labels, 400 párrafos. + +**Forma (radios de Fluent 2):** `--r-sm: 4px` en controles (botones, campos, checkboxes) y +`--r-lg: 8px` en superficies elevadas (tarjetas, diálogos, flyouts, menús, y la propia ventana). +No usar esquinas totalmente cuadradas ni "pill" (`999px`) salvo en `ToggleSwitch`, `badge` y `avatar`. + +**Iconografía:** un solo set en toda la app. Dos opciones válidas: +- **Lucide** (la que usa la muestra viva, `lucide.min.js` local) — mismo estándar que ya usa el grupo + en sus apps web (`UI-UX-LINKEDCORP.md`), da continuidad visual entre plataformas y permite compartir + nombres de ícono entre el proyecto web y el de escritorio de un mismo cliente. +- **Segoe Fluent Icons** (fuente de glifos del propio Windows) — 100% nativo, cero peso adicional, pero + solo tiene sentido en una implementación WinUI/WPF nativa (no está disponible como webfont fuera de Windows). +- Los íconos **heredan el color del contexto** (activo = `accent`, texto = `body`/`muted`). No mezclar + ambos sets en la misma app. + +--- + +## 2. Mensajes del sistema — jerarquía de avisos (REGLA DE ORO) + +A diferencia de la web (donde todo pasa por un único `AppDialog`), una app de escritorio Windows tiene +**cuatro** mecanismos de aviso, cada uno con un propósito distinto. Elegir el correcto es parte de la +identidad: + +| Mecanismo | Para qué | Bloquea | Ejemplo | +|---|---|---|---| +| **ContentDialog** (modal) | **toda decisión** del usuario (confirmar, elegir, error crítico) | Sí | "¿Eliminar el cliente CLI-002?" | +| **InfoBar** | estado o aviso **no bloqueante**, embebido en el contenido de la pantalla | No | "Sincronización activa", "Licencia por vencer" | +| **Toast/notificación** | evento de **fondo**, sin interacción esperada, autodesaparece | No | "Copia de seguridad completada" | +| **Flyout** | acción o info **contextual**, anclada a un control, se cierra al hacer clic fuera | No (light-dismiss) | menú de usuario, "¿seguro?" ligado a un botón puntual | + +**Nunca** `MessageBox`/`alert`/`confirm` del sistema operativo ni del navegador si el shell es +web-based: siempre el `ContentDialog` propio, para mantener consistencia visual entre Windows y el resto +de plataformas del grupo. + +Contrato del `ContentDialog` (equivalente al `openDialog` del template web): +```js +openDialog({ + tone: 'info' | 'success' | 'warning' | 'error', // franja superior + color del ícono/título + title: 'Título claro', + message: 'Descripción en lenguaje del usuario', + actions: [ // opcional; sin actions -> botón "Cerrar" + { label: 'Cancelar', variant: 'standard' }, + { label: 'Eliminar', variant: 'danger', onClick: async () => { ... }, keepOpen: false }, + ], +}); +``` +- Sin `actions` ⇒ botón "Cerrar" (`accent`) automático. `keepOpen: true` mantiene el diálogo abierto + tras el `onClick` (procesos largos). +- **Sets de botones estándar** (misma convención que la identidad web): Aceptar/Cancelar, + Sí/No, Eliminar(danger)/Cancelar, Guardar/Descartar. **Acción primaria a la derecha**, Cancelar/No a + la izquierda. Los botones de decisión **siempre muestran texto** (nunca solo ícono). +- El `ContentDialog` se centra sobre la **ventana de la app**, no sobre toda la pantalla del monitor. + +--- + +## 3. Kit de componentes (resumen; ver el markup/CSS de referencia en `UI-UX-WINDOWS.html`) + +- **Botones** — `accent` (relleno de color, acción primaria), `standard` (borde + fondo neutro), + `subtle` (transparente, solo hover), `danger` (borde/texto rojo). Alto `32px` (`sm` `26px`). Todo + botón compone **ícono (izq.) + texto**; en toolbars muy densas puede ir solo ícono con `tooltip` + obligatorio indicando la acción. +- **Campos** — `TextBox`, `NumberBox` (con spinner +/-), `ComboBox` propio (desplegable redondeado, + con buscador si la lista tiene **≥10 ítems**; nunca el ` + + + {files.length === 0 &&
    {t('common_empty')}
    } + {files.map((f) => ( +
    + +
    +
    {f.original_name}
    +
    {f.uploaded_by_name} · {(f.size / 1024).toFixed(0)} KB
    +
    + + +
    + ))} + + ); +} diff --git a/frontend/src/components/Badge.jsx b/frontend/src/components/Badge.jsx new file mode 100644 index 0000000..0ea0f44 --- /dev/null +++ b/frontend/src/components/Badge.jsx @@ -0,0 +1,9 @@ +const COLOR_MAP = { + accent: 'badge-accent', success: 'badge-success', warning: 'badge-warning', + danger: 'badge-danger', muted: 'badge-muted', +}; + +export function Badge({ color = 'accent', children }) { + const cls = COLOR_MAP[color] || 'badge-accent'; + return {children}; +} diff --git a/frontend/src/components/Button.jsx b/frontend/src/components/Button.jsx new file mode 100644 index 0000000..b699df6 --- /dev/null +++ b/frontend/src/components/Button.jsx @@ -0,0 +1,9 @@ +export function Button({ variant = 'standard', size, icon: Icon, children, className = '', ...rest }) { + const sizeClass = size === 'sm' ? 'btn-sm' : ''; + return ( + + ); +} diff --git a/frontend/src/components/ComboBox.jsx b/frontend/src/components/ComboBox.jsx new file mode 100644 index 0000000..54b07ba --- /dev/null +++ b/frontend/src/components/ComboBox.jsx @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, Search } from 'lucide-react'; + +// Combo con buscador (regla del grupo: nunca el setQuery(e.target.value)} + placeholder="Buscar..." + /> + + )} + {filtered.length === 0 &&
    Sin resultados
    } + {filtered.map((o) => ( +
    { onChange(o.value); setOpen(false); setQuery(''); }} + > + {o.label} +
    + ))} + + )} + + ); +} diff --git a/frontend/src/components/CommentsPanel.jsx b/frontend/src/components/CommentsPanel.jsx new file mode 100644 index 0000000..1256913 --- /dev/null +++ b/frontend/src/components/CommentsPanel.jsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { Send } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; +import { useAsync, useAction } from '../hooks/useAsync.js'; +import { listComments, createComment } from '../services/comments.js'; +import { Button } from './Button.jsx'; + +export function CommentsPanel({ entityType, entityId }) { + const { t } = useUi(); + const runAction = useAction(); + const { data: comments, loading, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]); + const [message, setMessage] = useState(''); + + async function send() { + if (!message.trim()) return; + await runAction(async () => { + await createComment(entityType, entityId, message.trim()); + setMessage(''); + await reload(); + }); + } + + if (loading || !comments) return
    {t('common_loading')}
    ; + + return ( +
    + {comments.length === 0 &&
    {t('common_empty')}
    } + {comments.map((c) => ( +
    +
    {c.user_name} · {c.created_at}
    +
    {c.message}
    +
    + ))} +
    + setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} /> + +
    +
    + ); +} diff --git a/frontend/src/components/ContentDialog.jsx b/frontend/src/components/ContentDialog.jsx new file mode 100644 index 0000000..4795452 --- /dev/null +++ b/frontend/src/components/ContentDialog.jsx @@ -0,0 +1,38 @@ +import { useUi } from '../context/ui-context.jsx'; +import { Button } from './Button.jsx'; + +const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' }; + +// ContentDialog global — equivalente al AppDialog de la identidad web. Contrato: +// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] }) +export function ContentDialogHost() { + const { dialog, closeDialog, t } = useUi(); + if (!dialog) return null; + + const tone = dialog.tone || 'info'; + const actions = dialog.actions?.length ? dialog.actions : [{ label: t('common_close'), variant: 'accent' }]; + + async function handleClick(action) { + if (action.onClick) await action.onClick(); + if (!action.keepOpen) closeDialog(); + } + + return ( +
    { if (e.target === e.currentTarget) closeDialog(); }}> +
    +
    +
    +
    {dialog.title}
    +
    {dialog.message}
    +
    +
    + {actions.map((action, i) => ( + + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/components/Expander.jsx b/frontend/src/components/Expander.jsx new file mode 100644 index 0000000..300aa92 --- /dev/null +++ b/frontend/src/components/Expander.jsx @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import { ChevronRight } from 'lucide-react'; + +export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
    +
    setOpen((v) => !v)}> + {Icon && } +
    +
    {title}
    + {description &&
    {description}
    } +
    + +
    + {open &&
    {children}
    } +
    + ); +} diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx new file mode 100644 index 0000000..656506e --- /dev/null +++ b/frontend/src/components/Field.jsx @@ -0,0 +1,13 @@ +export function Field({ label, children, hint }) { + return ( +
    + {label && } + {children} + {hint && {hint}} +
    + ); +} + +export function FieldRow({ children }) { + return
    {children}
    ; +} diff --git a/frontend/src/components/Flyout.jsx b/frontend/src/components/Flyout.jsx new file mode 100644 index 0000000..a509e0e --- /dev/null +++ b/frontend/src/components/Flyout.jsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +// Flyout generico con light-dismiss: click fuera o Esc cierra. trigger recibe { onClick, ref }. +export function Flyout({ trigger, children, align = 'end' }) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState({ top: 0, left: 0 }); + const anchorRef = useRef(null); + + function openFlyout() { + const rect = anchorRef.current.getBoundingClientRect(); + setPos({ + top: rect.bottom + 6, + left: align === 'end' ? rect.right : rect.left, + }); + setOpen(true); + } + + useEffect(() => { + if (!open) return; + function onKey(e) { if (e.key === 'Escape') setOpen(false); } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + + return ( + <> + {trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })} + {open && createPortal( + <> +
    setOpen(false)} /> +
    setOpen(false)} + > + {children} +
    + , + document.body + )} + + ); +} + +export function FlyoutItem({ icon: Icon, danger, children, ...rest }) { + return ( + + ); +} + +export function FlyoutSeparator() { + return
    ; +} diff --git a/frontend/src/components/InfoBar.jsx b/frontend/src/components/InfoBar.jsx new file mode 100644 index 0000000..586741c --- /dev/null +++ b/frontend/src/components/InfoBar.jsx @@ -0,0 +1,21 @@ +import { useState } from 'react'; +import { Info, CheckCircle2, AlertTriangle, XCircle, X } from 'lucide-react'; + +const ICONS = { info: Info, success: CheckCircle2, warning: AlertTriangle, error: XCircle }; + +export function InfoBar({ tone = 'info', children, dismissible = true }) { + const [visible, setVisible] = useState(true); + if (!visible) return null; + const Icon = ICONS[tone] || Info; + return ( +
    + +
    {children}
    + {dismissible && ( + + )} +
    + ); +} diff --git a/frontend/src/components/KpiCard.jsx b/frontend/src/components/KpiCard.jsx new file mode 100644 index 0000000..93619c9 --- /dev/null +++ b/frontend/src/components/KpiCard.jsx @@ -0,0 +1,8 @@ +export function KpiCard({ label, value }) { + return ( +
    +
    {value}
    +
    {label}
    +
    + ); +} diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx new file mode 100644 index 0000000..e0d40ea --- /dev/null +++ b/frontend/src/components/ListView.jsx @@ -0,0 +1,72 @@ +import { useMemo, useState } from 'react'; +import { MoreVertical } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; +import { Flyout, FlyoutItem } from './Flyout.jsx'; + +// ListView/DataGrid: columnas ordenables, buscador ligero sobre el haystack completo, clic en +// fila abre detalle, boton "..." abre el menu de acciones (equivalente al clic derecho). +export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey }) { + const { t } = useUi(); + const [sort, setSort] = useState({ key: null, dir: 1 }); + + const filtered = useMemo(() => { + if (!searchValue) return rows; + const q = searchValue.toLowerCase(); + return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q)); + }, [rows, searchValue, getSearchHaystack]); + + const sorted = useMemo(() => { + if (!sort.key) return filtered; + const col = columns.find((c) => c.key === sort.key); + return [...filtered].sort((a, b) => { + const va = col.sortValue ? col.sortValue(a) : a[sort.key]; + const vb = col.sortValue ? col.sortValue(b) : b[sort.key]; + if (va === vb) return 0; + return (va > vb ? 1 : -1) * sort.dir; + }); + }, [filtered, sort, columns]); + + function toggleSort(key) { + setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 })); + } + + return ( +
    + + + + {columns.map((col) => ( + + ))} + {rowActions && + + + {sorted.map((row) => ( + onRowClick?.(row)}> + {columns.map((col) => ( + + ))} + {rowActions && ( + + )} + + ))} + +
    col.sortable !== false && toggleSort(col.key)}> + {col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''} + } +
    {col.render ? col.render(row) : row[col.key]} e.stopPropagation()}> + ( + + )}> + {rowActions(row).map((action, i) => ( + + {action.label} + + ))} + +
    + {sorted.length === 0 &&
    {t('common_empty')}
    } +
    + ); +} diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx new file mode 100644 index 0000000..6c8b058 --- /dev/null +++ b/frontend/src/components/NavigationView.jsx @@ -0,0 +1,31 @@ +import { NavLink } from 'react-router-dom'; +import { LayoutDashboard, FolderKanban, Building2, Settings } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; + +const ITEMS = [ + { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, + { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, + { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, + { to: '/configuracion', icon: Settings, labelKey: 'nav_config' }, +]; + +export function NavigationView({ mode }) { + const { t } = useUi(); + return ( + + ); +} diff --git a/frontend/src/components/SearchInput.jsx b/frontend/src/components/SearchInput.jsx new file mode 100644 index 0000000..9381252 --- /dev/null +++ b/frontend/src/components/SearchInput.jsx @@ -0,0 +1,12 @@ +import { Search } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; + +export function SearchInput({ value, onChange, placeholder }) { + const { t } = useUi(); + return ( +
    + + onChange(e.target.value)} placeholder={placeholder || t('common_search')} /> +
    + ); +} diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx new file mode 100644 index 0000000..4fe126f --- /dev/null +++ b/frontend/src/components/StatusBadge.jsx @@ -0,0 +1,26 @@ +import { Badge } from './Badge.jsx'; +import { useUi } from '../context/ui-context.jsx'; + +export function StatusBadge({ name, color }) { + return {name}; +} + +const DUE_COLOR = { on_time: 'success', warning: 'warning', today: 'warning', overdue: 'danger', none: 'muted' }; +const DUE_KEY = { on_time: 'due_on_time', warning: 'due_warning', today: 'due_today', overdue: 'due_overdue' }; + +export function DueBadge({ status }) { + const { t } = useUi(); + if (!status || status === 'none') return null; + return {t(DUE_KEY[status])}; +} + +export function ProgressBar({ value }) { + return ( +
    +
    +
    +
    + {Math.round(value)}% +
    + ); +} diff --git a/frontend/src/components/Tabs.jsx b/frontend/src/components/Tabs.jsx new file mode 100644 index 0000000..1cbb849 --- /dev/null +++ b/frontend/src/components/Tabs.jsx @@ -0,0 +1,11 @@ +export function Tabs({ tabs, active, onChange }) { + return ( +
    + {tabs.map((tab) => ( + + ))} +
    + ); +} diff --git a/frontend/src/components/ToastHost.jsx b/frontend/src/components/ToastHost.jsx new file mode 100644 index 0000000..dc87014 --- /dev/null +++ b/frontend/src/components/ToastHost.jsx @@ -0,0 +1,13 @@ +import { useUi } from '../context/ui-context.jsx'; + +export function ToastHost() { + const { toasts } = useUi(); + if (!toasts.length) return null; + return ( +
    + {toasts.map((toast) => ( +
    {toast.message}
    + ))} +
    + ); +} diff --git a/frontend/src/components/Treeview.jsx b/frontend/src/components/Treeview.jsx new file mode 100644 index 0000000..bf7f7df --- /dev/null +++ b/frontend/src/components/Treeview.jsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { ChevronRight, ChevronDown, Folder } from 'lucide-react'; + +function TreeNode({ node, level, onSelect, selectedId }) { + const [open, setOpen] = useState(true); + const hasChildren = node.children && node.children.length > 0; + return ( +
    +
    onSelect?.(node)} + > + {hasChildren ? ( + + ) : ( + + )} + + {node.name} +
    + {hasChildren && open && node.children.map((child) => ( + + ))} +
    + ); +} + +export function Treeview({ nodes, onSelect, selectedId }) { + return ( +
    + {nodes.map((node) => ( + + ))} +
    + ); +} diff --git a/frontend/src/context/auth-context.jsx b/frontend/src/context/auth-context.jsx new file mode 100644 index 0000000..8007ca8 --- /dev/null +++ b/frontend/src/context/auth-context.jsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react'; +import { api, setUnauthorizedHandler } from '../services/api.js'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); + + const logout = useCallback(() => { + api.setToken(null); + setUser(null); + }, []); + + useEffect(() => { + setUnauthorizedHandler(() => logout()); + }, [logout]); + + useEffect(() => { + const token = localStorage.getItem('plc_token'); + if (!token) { setReady(true); return; } + api.get('/auth/me') + .then((me) => setUser(me)) + .catch(() => logout()) + .finally(() => setReady(true)); + }, [logout]); + + const login = useCallback(async (email, password) => { + const result = await api.post('/auth/login', { email, password }); + api.setToken(result.token); + setUser(result.user); + return result.user; + }, []); + + const value = useMemo(() => ({ user, ready, login, logout }), [user, ready, login, logout]); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider'); + return ctx; +} diff --git a/frontend/src/context/ui-context.jsx b/frontend/src/context/ui-context.jsx new file mode 100644 index 0000000..1f9635a --- /dev/null +++ b/frontend/src/context/ui-context.jsx @@ -0,0 +1,65 @@ +import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react'; +import { messages } from '../i18n/messages.js'; + +const UiContext = createContext(null); + +function readStored(key, fallback) { + try { + return localStorage.getItem(key) || fallback; + } catch { + return fallback; + } +} + +export function UiProvider({ children }) { + const [theme, setThemeState] = useState(() => readStored('plc_theme', 'dark')); + const [lang, setLangState] = useState(() => readStored('plc_lang', 'es-EC')); + const [buttonMode, setButtonModeState] = useState(() => readStored('plc_button_mode', 'icon-text')); + const [dialog, setDialog] = useState(null); + const [toasts, setToasts] = useState([]); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('plc_theme', theme); + }, [theme]); + + useEffect(() => { + document.documentElement.setAttribute('lang', lang); + localStorage.setItem('plc_lang', lang); + }, [lang]); + + useEffect(() => { + document.documentElement.setAttribute('data-button-mode', buttonMode); + localStorage.setItem('plc_button_mode', buttonMode); + }, [buttonMode]); + + const t = useCallback((key) => messages[lang]?.[key] ?? messages['es-EC'][key] ?? key, [lang]); + + const openDialog = useCallback((config) => setDialog(config), []); + const closeDialog = useCallback(() => setDialog(null), []); + + const pushToast = useCallback((toast) => { + const id = Date.now() + Math.random(); + setToasts((prev) => [...prev, { id, tone: 'info', ...toast }]); + setTimeout(() => setToasts((prev) => prev.filter((t2) => t2.id !== id)), 4000); + }, []); + + const value = useMemo( + () => ({ + theme, setTheme: setThemeState, + lang, setLang: setLangState, + buttonMode, setButtonMode: setButtonModeState, + t, dialog, openDialog, closeDialog, + toasts, pushToast, + }), + [theme, lang, buttonMode, t, dialog, openDialog, closeDialog, toasts, pushToast] + ); + + return {children}; +} + +export function useUi() { + const ctx = useContext(UiContext); + if (!ctx) throw new Error('useUi debe usarse dentro de UiProvider'); + return ctx; +} diff --git a/frontend/src/hooks/useAsync.js b/frontend/src/hooks/useAsync.js new file mode 100644 index 0000000..2408565 --- /dev/null +++ b/frontend/src/hooks/useAsync.js @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useUi } from '../context/ui-context.jsx'; + +// Abstrae el patron repetido loading/try-catch/error para llamadas a la API. +export function useAsync(fn, deps = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fn(); + setData(result); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + useEffect(() => { reload(); }, [reload]); + + return { data, loading, error, reload, setData }; +} + +// Ejecuta una accion mostrando error via ContentDialog si falla. +export function useAction() { + const { openDialog, t } = useUi(); + return useCallback(async (fn) => { + try { + return await fn(); + } catch (err) { + openDialog({ tone: 'error', title: t('common_error'), message: err.message }); + throw err; + } + }, [openDialog, t]); +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js new file mode 100644 index 0000000..3785d48 --- /dev/null +++ b/frontend/src/i18n/messages.js @@ -0,0 +1,270 @@ +export const messages = { + 'es-EC': { + appName: 'LinkedCorp Proyectos', + nav_dashboard: 'Panel', + nav_projects: 'Proyectos', + nav_organization: 'Organizacion', + nav_config: 'Configuracion', + login_title: 'Iniciar sesion', + login_email: 'Correo', + login_password: 'Clave', + login_submit: 'Entrar', + login_error: 'Credenciales invalidas', + logout: 'Cerrar sesion', + common_new: 'Nuevo', + common_edit: 'Editar', + common_save: 'Guardar', + common_cancel: 'Cancelar', + common_delete: 'Eliminar', + common_close: 'Cerrar', + common_search: 'Buscar...', + common_actions: 'Acciones', + common_view: 'Ver', + common_yes: 'Si', + common_no: 'No', + common_loading: 'Cargando...', + common_empty: 'No hay datos para mostrar', + common_confirm_delete_title: 'Confirmar eliminacion', + common_confirm_delete_msg: '¿Seguro que deseas eliminar este elemento? Esta accion no se puede deshacer.', + common_saved: 'Cambios guardados', + common_error: 'Ocurrio un error', + common_name: 'Nombre', + common_description: 'Descripcion', + common_status: 'Estado', + common_priority: 'Prioridad', + common_responsible: 'Responsable', + common_department: 'Departamento', + common_active: 'Activo', + common_inactive: 'Inactivo', + common_all: 'Todos', + theme_light: 'Claro', + theme_dark: 'Oscuro', + lang_es: 'Espanol', + lang_en: 'Ingles', + button_mode_icon_text: 'Icono + texto', + button_mode_icon: 'Solo icono', + button_mode_text: 'Solo texto', + + dashboard_title: 'Panel principal', + dashboard_active_projects: 'Proyectos activos', + dashboard_overdue_projects: 'Proyectos atrasados', + dashboard_due_soon_projects: 'Proximos a vencer', + dashboard_pending_activities: 'Actividades pendientes', + dashboard_overdue_activities: 'Actividades vencidas', + dashboard_today_activities: 'Para hoy', + dashboard_average_progress: 'Avance promedio', + dashboard_my_activities: 'Mis actividades pendientes', + dashboard_projects_by_status: 'Proyectos por estado', + + projects_title: 'Proyectos', + project_code: 'Codigo', + project_new: 'Nuevo proyecto', + project_objective: 'Objetivo', + project_type: 'Tipo de proyecto', + project_owner: 'Responsable', + project_start_date: 'Fecha de inicio', + project_estimated_end_date: 'Fin estimado', + project_actual_end_date: 'Fin real', + project_progress: 'Avance', + project_requesting_department: 'Departamento solicitante', + project_responsible_department: 'Departamento responsable', + project_risk: 'Riesgo', + project_notes: 'Observaciones', + project_members: 'Miembros', + project_add_member: 'Agregar miembro', + risk_low: 'Bajo', + risk_medium: 'Medio', + risk_high: 'Alto', + + tab_info: 'Info', + tab_activities: 'Actividades', + tab_gantt: 'Gantt', + tab_calendar: 'Calendario', + tab_comments: 'Comentarios', + tab_documents: 'Documentos', + + activity_new: 'Nueva actividad', + activity_code: 'Codigo', + activity_start_date: 'Fecha inicio', + activity_due_date: 'Fecha limite', + activity_actual_end_date: 'Fecha real de fin', + activity_progress: 'Avance', + activity_milestone: 'Es hito', + activity_estimated_hours: 'Horas estimadas', + activity_dependencies: 'Dependencias', + activity_add_dependency: 'Agregar dependencia', + activity_depends_on: 'Depende de', + subtasks_title: 'Subtareas', + subtask_new: 'Nueva subtarea', + subtask_done: 'Hecha', + + due_on_time: 'En tiempo', + due_warning: 'Proximo a vencer', + due_today: 'Vence hoy', + due_overdue: 'Vencido', + + org_departments: 'Departamentos', + org_users: 'Usuarios', + department_new: 'Nuevo departamento', + department_parent: 'Departamento padre', + user_new: 'Nuevo usuario', + user_email: 'Correo', + user_role: 'Rol', + user_position: 'Cargo', + user_password: 'Clave inicial', + + config_title: 'Configuracion', + config_appearance: 'Apariencia', + config_catalogs: 'Catalogos', + config_priorities: 'Prioridades', + config_project_statuses: 'Estados de proyecto', + config_activity_statuses: 'Estados de actividad', + config_project_types: 'Tipos de proyecto', + + comments_title: 'Comentarios', + comment_placeholder: 'Escribe un comentario...', + comment_send: 'Enviar', + + documents_title: 'Documentos adjuntos', + document_upload: 'Subir archivo', + document_download: 'Descargar', + + notifications_title: 'Notificaciones', + notifications_empty: 'Sin notificaciones', + notifications_mark_all_read: 'Marcar todas como leidas', + }, + 'en-US': { + appName: 'LinkedCorp Projects', + nav_dashboard: 'Dashboard', + nav_projects: 'Projects', + nav_organization: 'Organization', + nav_config: 'Settings', + login_title: 'Sign in', + login_email: 'Email', + login_password: 'Password', + login_submit: 'Sign in', + login_error: 'Invalid credentials', + logout: 'Sign out', + common_new: 'New', + common_edit: 'Edit', + common_save: 'Save', + common_cancel: 'Cancel', + common_delete: 'Delete', + common_close: 'Close', + common_search: 'Search...', + common_actions: 'Actions', + common_view: 'View', + common_yes: 'Yes', + common_no: 'No', + common_loading: 'Loading...', + common_empty: 'No data to show', + common_confirm_delete_title: 'Confirm delete', + common_confirm_delete_msg: 'Are you sure you want to delete this item? This cannot be undone.', + common_saved: 'Changes saved', + common_error: 'An error occurred', + common_name: 'Name', + common_description: 'Description', + common_status: 'Status', + common_priority: 'Priority', + common_responsible: 'Owner', + common_department: 'Department', + common_active: 'Active', + common_inactive: 'Inactive', + common_all: 'All', + theme_light: 'Light', + theme_dark: 'Dark', + lang_es: 'Spanish', + lang_en: 'English', + button_mode_icon_text: 'Icon + text', + button_mode_icon: 'Icon only', + button_mode_text: 'Text only', + + dashboard_title: 'Dashboard', + dashboard_active_projects: 'Active projects', + dashboard_overdue_projects: 'Overdue projects', + dashboard_due_soon_projects: 'Due soon', + dashboard_pending_activities: 'Pending activities', + dashboard_overdue_activities: 'Overdue activities', + dashboard_today_activities: 'Due today', + dashboard_average_progress: 'Average progress', + dashboard_my_activities: 'My pending activities', + dashboard_projects_by_status: 'Projects by status', + + projects_title: 'Projects', + project_code: 'Code', + project_new: 'New project', + project_objective: 'Objective', + project_type: 'Project type', + project_owner: 'Owner', + project_start_date: 'Start date', + project_estimated_end_date: 'Estimated end', + project_actual_end_date: 'Actual end', + project_progress: 'Progress', + project_requesting_department: 'Requesting department', + project_responsible_department: 'Responsible department', + project_risk: 'Risk', + project_notes: 'Notes', + project_members: 'Members', + project_add_member: 'Add member', + risk_low: 'Low', + risk_medium: 'Medium', + risk_high: 'High', + + tab_info: 'Info', + tab_activities: 'Activities', + tab_gantt: 'Gantt', + tab_calendar: 'Calendar', + tab_comments: 'Comments', + tab_documents: 'Documents', + + activity_new: 'New activity', + activity_code: 'Code', + activity_start_date: 'Start date', + activity_due_date: 'Due date', + activity_actual_end_date: 'Actual end date', + activity_progress: 'Progress', + activity_milestone: 'Is milestone', + activity_estimated_hours: 'Estimated hours', + activity_dependencies: 'Dependencies', + activity_add_dependency: 'Add dependency', + activity_depends_on: 'Depends on', + subtasks_title: 'Subtasks', + subtask_new: 'New subtask', + subtask_done: 'Done', + + due_on_time: 'On time', + due_warning: 'Due soon', + due_today: 'Due today', + due_overdue: 'Overdue', + + org_departments: 'Departments', + org_users: 'Users', + department_new: 'New department', + department_parent: 'Parent department', + user_new: 'New user', + user_email: 'Email', + user_role: 'Role', + user_position: 'Position', + user_password: 'Initial password', + + config_title: 'Settings', + config_appearance: 'Appearance', + config_catalogs: 'Catalogs', + config_priorities: 'Priorities', + config_project_statuses: 'Project statuses', + config_activity_statuses: 'Activity statuses', + config_project_types: 'Project types', + + comments_title: 'Comments', + comment_placeholder: 'Write a comment...', + comment_send: 'Send', + + documents_title: 'Attachments', + document_upload: 'Upload file', + document_download: 'Download', + + notifications_title: 'Notifications', + notifications_empty: 'No notifications', + notifications_mark_all_read: 'Mark all as read', + }, +}; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..721d3eb --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './app/App.jsx'; +import './styles/tokens.css'; +import './styles/app.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/pages/Configuracion/CatalogEditor.jsx b/frontend/src/pages/Configuracion/CatalogEditor.jsx new file mode 100644 index 0000000..816e9fd --- /dev/null +++ b/frontend/src/pages/Configuracion/CatalogEditor.jsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Plus, Save, Trash2 } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { listCatalog, createCatalogItem, updateCatalogItem, deleteCatalogItem } from '../../services/catalogs.js'; +import { ListView } from '../../components/ListView.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { Badge } from '../../components/Badge.jsx'; + +const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c })); + +function emptyForm(fields) { + const form = {}; + for (const f of fields) form[f.key] = f.type === 'checkbox' ? false : ''; + return form; +} + +// Editor generico reutilizable para los 4 catalogos (prioridades, estados, tipos de proyecto). +export function CatalogEditor({ catalogKey, fields, title }) { + const { t, openDialog, pushToast } = useUi(); + const runAction = useAction(); + const { data: items, loading, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]); + const [editing, setEditing] = useState(false); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState(emptyForm(fields)); + + function startNew() { + setSelected(null); + setForm(emptyForm(fields)); + setEditing(true); + } + + function startEdit(item) { + setSelected(item); + const next = {}; + for (const f of fields) next[f.key] = item[f.key]; + setForm(next); + setEditing(true); + } + + async function save() { + await runAction(async () => { + if (selected) await updateCatalogItem(catalogKey, selected.id, form); + else await createCatalogItem(catalogKey, form); + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } + + function confirmDelete(item) { + 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 deleteCatalogItem(catalogKey, item.id); + await reload(); + setEditing(false); + }) }, + ], + }); + } + + const columns = fields.map((f) => ({ + key: f.key, + label: f.label, + render: f.type === 'color' ? (row) => {row[f.key]} + : f.type === 'checkbox' ? (row) => (row[f.key] ? t('common_yes') : t('common_no')) + : undefined, + })); + + if (loading || !items) return
    {t('common_loading')}
    ; + + return ( +
    +
    +

    {title}

    + +
    + r.id} + onRowClick={startEdit} + rowActions={(r) => [ + { label: t('common_edit'), onClick: () => startEdit(r) }, + { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) }, + ]} + /> + + {editing && ( +
    + + {fields.map((f) => ( + + {f.type === 'color' ? ( + setForm((s) => ({ ...s, [f.key]: v }))} /> + ) : f.type === 'checkbox' ? ( + + ) : ( + setForm((s) => ({ ...s, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))} + /> + )} + + ))} + +
    + + +
    +
    + )} +
    + ); +} diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx new file mode 100644 index 0000000..caca1ae --- /dev/null +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -0,0 +1,89 @@ +import { Palette, ListChecks, Flag, FolderKanban, Layers } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { Expander } from '../../components/Expander.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { CatalogEditor } from './CatalogEditor.jsx'; + +export default function ConfiguracionPage() { + const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi(); + + return ( +
    +

    {t('config_title')}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + ); +} diff --git a/frontend/src/pages/Dashboard/DashboardPage.jsx b/frontend/src/pages/Dashboard/DashboardPage.jsx new file mode 100644 index 0000000..06263c6 --- /dev/null +++ b/frontend/src/pages/Dashboard/DashboardPage.jsx @@ -0,0 +1,57 @@ +import { useNavigate } from 'react-router-dom'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync } from '../../hooks/useAsync.js'; +import { getSummary } from '../../services/dashboard.js'; +import { KpiCard } from '../../components/KpiCard.jsx'; +import { ProgressBar, DueBadge } from '../../components/StatusBadge.jsx'; + +export default function DashboardPage() { + const { t } = useUi(); + const navigate = useNavigate(); + const { data, loading } = useAsync(getSummary, []); + + if (loading || !data) return
    {t('common_loading')}
    ; + const { kpis, myActivities, projectsByStatus } = data; + + return ( +
    +

    {t('dashboard_title')}

    + +
    + + + + + + + +
    + +
    +

    {t('dashboard_my_activities')}

    + {myActivities.length === 0 &&
    {t('common_empty')}
    } + {myActivities.map((a) => ( +
    navigate(`/proyectos/${a.project_id}`)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' }}> +
    +
    {a.name}
    +
    {a.project_name} · {a.due_date || '—'}
    +
    +
    + +
    + ))} +
    + +
    +

    {t('dashboard_projects_by_status')}

    + {projectsByStatus.map((s) => ( +
    + + {s.name} + {s.total} +
    + ))} +
    +
    + ); +} diff --git a/frontend/src/pages/Login/LoginPage.jsx b/frontend/src/pages/Login/LoginPage.jsx new file mode 100644 index 0000000..ac0e7b5 --- /dev/null +++ b/frontend/src/pages/Login/LoginPage.jsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { LogIn } from 'lucide-react'; +import { useAuth } from '../../context/auth-context.jsx'; +import { useUi } from '../../context/ui-context.jsx'; +import { Field } from '../../components/Field.jsx'; +import { Button } from '../../components/Button.jsx'; + +export default function LoginPage() { + const { login } = useAuth(); + const { t } = useUi(); + const navigate = useNavigate(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(email, password); + navigate('/', { replace: true }); + } catch { + setError(t('login_error')); + } finally { + setLoading(false); + } + } + + return ( +
    +
    +
    +
    LC
    +
    {t('login_title')}
    +
    + {error &&
    {error}
    } + + setEmail(e.target.value)} required autoFocus /> + + + setPassword(e.target.value)} required /> + + +
    +
    + ); +} diff --git a/frontend/src/pages/Organizacion/DepartmentsPanel.jsx b/frontend/src/pages/Organizacion/DepartmentsPanel.jsx new file mode 100644 index 0000000..8ac78b5 --- /dev/null +++ b/frontend/src/pages/Organizacion/DepartmentsPanel.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { Plus, Save, Trash2 } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { listDepartments, createDepartment, updateDepartment, deleteDepartment } from '../../services/departments.js'; +import { Treeview } from '../../components/Treeview.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; + +function flatten(nodes, acc = []) { + for (const n of nodes) { + acc.push(n); + if (n.children?.length) flatten(n.children, acc); + } + return acc; +} + +export default function DepartmentsPanel() { + const { t, openDialog, pushToast } = useUi(); + const runAction = useAction(); + const { data: tree, loading, reload } = useAsync(listDepartments, []); + const [selected, setSelected] = useState(null); + const [editing, setEditing] = useState(false); + const [form, setForm] = useState({ name: '', parentId: '' }); + + const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : []; + + function startNew() { + setSelected(null); + setForm({ name: '', parentId: '' }); + setEditing(true); + } + + function startEdit(node) { + setSelected(node); + setForm({ name: node.name, parentId: node.parent_id || '' }); + setEditing(true); + } + + async function save() { + if (!form.name.trim()) return; + await runAction(async () => { + const payload = { name: form.name.trim(), parentId: form.parentId || null }; + if (selected) await updateDepartment(selected.id, payload); + else await createDepartment(payload); + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } + + function confirmDelete(node) { + 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 deleteDepartment(node.id); + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + + if (loading || !tree) return
    {t('common_loading')}
    ; + + return ( +
    +
    +
    +

    {t('org_departments')}

    + +
    + +
    + + {editing && ( +
    +

    {selected ? t('common_edit') : t('department_new')}

    + + setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> + + + o.value !== selected?.id)]} + value={form.parentId} + onChange={(v) => setForm((f) => ({ ...f, parentId: v }))} + /> + +
    + + + {selected && } +
    +
    + )} +
    + ); +} diff --git a/frontend/src/pages/Organizacion/OrganizacionPage.jsx b/frontend/src/pages/Organizacion/OrganizacionPage.jsx new file mode 100644 index 0000000..df76540 --- /dev/null +++ b/frontend/src/pages/Organizacion/OrganizacionPage.jsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { useUi } from '../../context/ui-context.jsx'; +import { Tabs } from '../../components/Tabs.jsx'; +import DepartmentsPanel from './DepartmentsPanel.jsx'; +import UsersPanel from './UsersPanel.jsx'; + +export default function OrganizacionPage() { + const { t } = useUi(); + const [tab, setTab] = useState('departments'); + + return ( +
    +

    {t('nav_organization')}

    + + {tab === 'departments' ? : } +
    + ); +} diff --git a/frontend/src/pages/Organizacion/UsersPanel.jsx b/frontend/src/pages/Organizacion/UsersPanel.jsx new file mode 100644 index 0000000..07d3be4 --- /dev/null +++ b/frontend/src/pages/Organizacion/UsersPanel.jsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { Plus, Save, UserX, UserCheck } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { listUsers, listRoles, createUser, updateUser, setUserActive } from '../../services/users.js'; +import { listDepartments } from '../../services/departments.js'; +import { ListView } from '../../components/ListView.jsx'; +import { SearchInput } from '../../components/SearchInput.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { Badge } from '../../components/Badge.jsx'; + +function flatten(nodes, acc = []) { + for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); } + return acc; +} + +const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' }; + +export default function UsersPanel() { + const { t, pushToast } = useUi(); + const runAction = useAction(); + const { data: users, loading, reload } = useAsync(listUsers, []); + const { data: roles } = useAsync(listRoles, []); + const { data: deptTree } = useAsync(listDepartments, []); + + const [search, setSearch] = useState(''); + const [editing, setEditing] = useState(false); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + + const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : []; + const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name })); + + function startNew() { + setSelected(null); + setForm(EMPTY_FORM); + setEditing(true); + } + + function startEdit(user) { + setSelected(user); + setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' }); + setEditing(true); + } + + async function save() { + if (!form.name.trim() || !form.email.trim() || !form.roleId) return; + await runAction(async () => { + if (selected) { + await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null }); + } else { + if (!form.password) return; + await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password }); + } + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } + + async function toggleActive(user) { + await runAction(async () => { + await setUserActive(user.id, !user.is_active); + await reload(); + }); + } + + const columns = [ + { key: 'name', label: t('common_name') }, + { key: 'email', label: t('user_email') }, + { key: 'role_name', label: t('user_role') }, + { key: 'department_name', label: t('common_department'), render: (r) => r.department_name || '—' }, + { key: 'is_active', label: t('common_status'), sortable: false, render: (r) => ( + {r.is_active ? t('common_active') : t('common_inactive')} + ) }, + ]; + + if (loading || !users) return
    {t('common_loading')}
    ; + + return ( +
    +
    +
    + +
    + +
    + + `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`} + getRowKey={(u) => u.id} + onRowClick={startEdit} + rowActions={(u) => [ + { label: t('common_edit'), onClick: () => startEdit(u) }, + u.is_active + ? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) } + : { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) }, + ]} + /> + + {editing && ( +
    +

    {selected ? t('common_edit') : t('user_new')}

    + + + setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> + + + setForm((f) => ({ ...f, email: e.target.value }))} /> + + + + + setForm((f) => ({ ...f, roleId: v }))} /> + + + setForm((f) => ({ ...f, departmentId: v }))} /> + + + + + setForm((f) => ({ ...f, position: e.target.value }))} /> + + {!selected && ( + + setForm((f) => ({ ...f, password: e.target.value }))} /> + + )} + +
    + + +
    +
    + )} +
    + ); +} diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx new file mode 100644 index 0000000..79674ec --- /dev/null +++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx @@ -0,0 +1,222 @@ +import { useEffect, useState } from 'react'; +import { Pencil, Save, X, Plus, Trash2, Link2 } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { updateActivity, addDependency, removeDependency } from '../../services/activities.js'; +import { listSubtasks, createSubtask, setSubtaskDone, deleteSubtask } from '../../services/subtasks.js'; +import { listCatalog } from '../../services/catalogs.js'; +import { listUsers } from '../../services/users.js'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx'; +import { Tabs } from '../../components/Tabs.jsx'; +import { CommentsPanel } from '../../components/CommentsPanel.jsx'; +import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx'; + +function toForm(a) { + return { + name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '', + priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '', + actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '', + progressPct: a.progress_pct, + }; +} + +export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, onClose }) { + const { t, pushToast } = useUi(); + const runAction = useAction(); + const { data: priorities } = useAsync(() => listCatalog('priorities'), []); + const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []); + const { data: users } = useAsync(listUsers, []); + const { data: subtasks, reload: reloadSubtasks } = useAsync(() => listSubtasks(activity.id), [activity.id]); + + const [tab, setTab] = useState('detail'); + const [editing, setEditing] = useState(false); + const [form, setForm] = useState(() => toForm(activity)); + const [newSubtask, setNewSubtask] = useState(''); + const [addingDep, setAddingDep] = useState(''); + + useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]); + + const hasSubtasks = subtasks && subtasks.length > 0; + const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name })); + const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name })); + const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name })); + const depOptions = (siblingActivities || []) + .filter((a) => a.id !== activity.id && !activity.dependencies?.some((d) => d.id === a.id)) + .map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` })); + + async function save() { + await runAction(async () => { + await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null }); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + onChanged(); + }); + } + + async function addSubtaskItem() { + if (!newSubtask.trim()) return; + await runAction(async () => { + await createSubtask(activity.id, { name: newSubtask.trim() }); + setNewSubtask(''); + await reloadSubtasks(); + onChanged(); + }); + } + + async function toggleSubtask(s) { + await runAction(async () => { await setSubtaskDone(s.id, !s.is_done); await reloadSubtasks(); onChanged(); }); + } + + async function removeSubtaskItem(s) { + await runAction(async () => { await deleteSubtask(s.id); await reloadSubtasks(); onChanged(); }); + } + + async function addDep() { + if (!addingDep) return; + await runAction(async () => { await addDependency(activity.id, Number(addingDep)); setAddingDep(''); onChanged(); }); + } + + async function removeDep(depId) { + await runAction(async () => { await removeDependency(activity.id, depId); onChanged(); }); + } + + return ( +
    +
    +

    {activity.code} — {activity.name}

    + +
    + + + + {tab === 'detail' && ( +
    +
    + {editing ? ( +
    + + +
    + ) : ( + + )} +
    + + {!editing ? ( +
    + + + +
    {activity.responsible_name || '—'}
    +
    +
    {activity.description || '—'}
    + +
    {activity.start_date || '—'}
    +
    {activity.due_date || '—'}
    +
    {activity.actual_end_date || '—'}
    +
    + + + {hasSubtasks && {t('subtasks_title')}} + +
    + ) : ( +
    + + setForm((f) => ({ ...f, name: e.target.value }))} /> + + +