diff --git a/API.md b/API.md index 2e83825..0ef71aa 100644 --- a/API.md +++ b/API.md @@ -76,6 +76,19 @@ ### Tareas (`/api/activities/:activityId/tasks`) - `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id` +### Tareas recurrentes (`/api/recurring-tasks`) — menú propio, independiente de Proyectos +- `GET` · `POST` · `PUT /api/recurring-tasks/:id` · `DELETE /api/recurring-tasks/:id` +- `POST /api/recurring-tasks/:id/complete` — cierra el ciclo actual y calcula el próximo + vencimiento sumando la periodicidad (`interval_value` + `interval_unit`). +- `POST /api/recurring-tasks/from-task/:taskId` — crea una tarea recurrente a partir de una tarea + puntual de una actividad (la original no se borra); mismo body que `POST /`. +- Body: `{ name, description?, projectId?, responsibleUserId?, intervalUnit: 'days'|'months'|'years', + intervalValue, nextDueDate, reminderDaysBefore, reminderHour: 0-23 }`. +- Recordatorio por correo: un job en el propio proceso del backend revisa cada 15 minutos + (`src/jobs/recurringTaskReminders.js`) y manda el correo cuando `next_due_date - + reminderDaysBefore` cae hoy, a la hora `reminderHour`. Un solo envío por ciclo + (`last_reminder_sent_at`, se limpia al completar el ciclo). + ### Comentarios (`/api/comments`) - `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 1d60f23..db33964 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -33,6 +33,7 @@ | `activities` | actividades de un proyecto | | `activity_dependencies` | dependencias simples entre actividades ("depende de") | | `tasks` | checklist liviano dentro de una actividad ("Tareas" en la UI; renombrada desde `subtasks` en v1.8.0) | +| `recurring_tasks` | recordatorios periódicos (ej. renovación de licencias), menú propio "Tareas recurrentes" | | `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) | | `attachments` | archivos adjuntos a proyecto o actividad | | `notifications` | notificaciones in-app por usuario | @@ -70,6 +71,13 @@ `pending_approval`** — a diferencia del autorregistro público, aquí quien lo invitó ya lo vetó al agregarlo a su proyecto. La fila de `project_members` se crea en el momento de invitar, no al aceptar, así que en cuanto acepta ya aparece como miembro sin pasos adicionales. +- `recurring_tasks`: `project_id`/`responsible_user_id` son **opcionales** (NULL) — se puede crear + directo desde "Tareas recurrentes" o promoviendo una tarea puntual de una actividad + (`project_id` queda como referencia del proyecto de origen, la tarea puntual original NO se + borra). `last_reminder_sent_at` se limpia a NULL cada vez que se completa un ciclo + (`POST /:id/complete`, que suma `interval_value`/`interval_unit` a `next_due_date`) — es lo que + evita reenviar el correo dos veces en el mismo ciclo. El envío real lo hace un job en el propio + proceso del backend (`setInterval` cada 15 min, sin cron del SO ni cola nueva). - `system_settings.value` para `smtp_pass_enc` va **cifrado** (AES-256-GCM, `ENCRYPTION_KEY` del `.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`). - Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con diff --git a/CHANGELOG.md b/CHANGELOG.md index 4962269..eb98569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog - project.linkedcorp.net +## [1.12.0] - 2026-09-11 +### Added +- **Tareas recurrentes**: menú propio en la barra lateral, independiente de Proyectos. Cada tarea + recurrente tiene periodicidad configurable (cada N días/meses/años), próximo vencimiento, + responsable y proyecto de origen opcional. "Completar ciclo" avanza el próximo vencimiento + sumando la periodicidad. Recordatorio por correo automático N días antes del vencimiento + (configurable, default 7) a una hora del día configurable — un job en el propio proceso del + backend revisa cada 15 minutos, sin infraestructura nueva. +- Desde la pestaña "Tareas" de una actividad, cada tarea puntual tiene un botón "Convertir en + recurrente" que la promueve a una tarea recurrente (la tarea original no se borra). + ## [1.11.0] - 2026-09-11 ### Added - Filtros en el listado de Proyectos: Estado, Prioridad (ambos ya soportados por el backend, solo diff --git a/VERSION b/VERSION index 1cac385..0eed1a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.0 +1.12.0 diff --git a/backend/database/migrations/010_recurring_tasks.sql b/backend/database/migrations/010_recurring_tasks.sql new file mode 100644 index 0000000..ae7f9fe --- /dev/null +++ b/backend/database/migrations/010_recurring_tasks.sql @@ -0,0 +1,24 @@ +-- Tareas recurrentes: recordatorios periodicos (ej. renovacion de licencias) con recordatorio +-- por correo N dias antes, a una hora configurable. Menu propio, independiente de Proyectos, pero +-- puede originarse promoviendo una tarea existente de un proyecto (project_id queda como +-- referencia/contexto, nunca obligatorio). +CREATE TABLE IF NOT EXISTS recurring_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(160) NOT NULL, + description TEXT NULL, + project_id INT NULL, + responsible_user_id INT NULL, + interval_unit ENUM('days','months','years') NOT NULL DEFAULT 'months', + interval_value INT NOT NULL DEFAULT 1, + next_due_date DATE NOT NULL, + reminder_days_before INT NOT NULL DEFAULT 7, + reminder_hour TINYINT NOT NULL DEFAULT 9, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_reminder_sent_at DATETIME 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_recurring_tasks_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL, + CONSTRAINT fk_recurring_tasks_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_recurring_tasks_created_by FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index 97cabca..4c89fb8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.11.0", + "version": "1.12.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index c6a9110..7bb2d97 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,6 +17,7 @@ import notificationsRoutes from './modules/notifications/routes.js'; import dashboardRoutes from './modules/dashboard/routes.js'; import auditRoutes from './modules/audit/routes.js'; import settingsRoutes from './modules/settings/routes.js'; +import recurringTasksRoutes from './modules/recurring-tasks/routes.js'; export const app = express(); @@ -38,6 +39,7 @@ app.use('/api/notifications', notificationsRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/audit', auditRoutes); app.use('/api/settings', settingsRoutes); +app.use('/api/recurring-tasks', recurringTasksRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/jobs/recurringTaskReminders.js b/backend/src/jobs/recurringTaskReminders.js new file mode 100644 index 0000000..3e62de2 --- /dev/null +++ b/backend/src/jobs/recurringTaskReminders.js @@ -0,0 +1,20 @@ +import { sendDueReminders } from '../modules/recurring-tasks/service.js'; + +const CHECK_INTERVAL_MS = 15 * 60 * 1000; + +// Revisa cada 15 minutos si hay tareas recurrentes que avisar hoy a esta hora (ver +// repository.findDueForReminder). Corre en el mismo proceso del backend — sin infraestructura +// nueva (cron del SO, cola, etc.), consistente con que este es un servicio unico de larga +// duracion administrado por systemd. +export function startRecurringTaskReminderJob() { + const run = async () => { + try { + const sent = await sendDueReminders(); + if (sent > 0) console.log(`[recurring-tasks] ${sent} recordatorio(s) enviado(s)`); + } catch (err) { + console.error('[recurring-tasks] error revisando recordatorios', err.message); + } + }; + run(); + setInterval(run, CHECK_INTERVAL_MS); +} diff --git a/backend/src/modules/recurring-tasks/controller.js b/backend/src/modules/recurring-tasks/controller.js new file mode 100644 index 0000000..215baea --- /dev/null +++ b/backend/src/modules/recurring-tasks/controller.js @@ -0,0 +1,27 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getRecurringTasks = asyncHandler(async (req, res) => { + ok(res, await service.listRecurringTasks()); +}); + +export const postRecurringTask = asyncHandler(async (req, res) => { + ok(res, await service.createRecurringTask(req.body, req.currentUser), 201); +}); + +export const putRecurringTask = asyncHandler(async (req, res) => { + ok(res, await service.updateRecurringTask(Number(req.params.id), req.body, req.currentUser)); +}); + +export const deleteRecurringTask = asyncHandler(async (req, res) => { + await service.removeRecurringTask(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); + +export const postCompleteCycle = asyncHandler(async (req, res) => { + ok(res, await service.completeCycle(Number(req.params.id), req.currentUser)); +}); + +export const postPromoteFromTask = asyncHandler(async (req, res) => { + ok(res, await service.promoteFromTask(Number(req.params.taskId), req.body, req.currentUser), 201); +}); diff --git a/backend/src/modules/recurring-tasks/repository.js b/backend/src/modules/recurring-tasks/repository.js new file mode 100644 index 0000000..f6a5d4a --- /dev/null +++ b/backend/src/modules/recurring-tasks/repository.js @@ -0,0 +1,100 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = ` + SELECT r.id, r.name, r.description, r.project_id, p.name AS project_name, + r.responsible_user_id, u.name AS responsible_name, + r.interval_unit, r.interval_value, r.next_due_date, + r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at, + r.created_by, r.created_at, r.updated_at + FROM recurring_tasks r + LEFT JOIN projects p ON p.id = r.project_id + LEFT JOIN users u ON u.id = r.responsible_user_id +`; + +export async function findAll() { + const [rows] = await pool.query(`${LIST_SELECT} ORDER BY r.next_due_date`); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query(`${LIST_SELECT} WHERE r.id = ?`, [id]); + return rows[0] || null; +} + +export async function create(data) { + const [result] = await pool.query( + `INSERT INTO recurring_tasks + (name, description, project_id, responsible_user_id, interval_unit, interval_value, + next_due_date, reminder_days_before, reminder_hour, is_active, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, + data.intervalUnit, data.intervalValue, data.nextDueDate, + data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, + data.isActive === false ? 0 : 1, data.createdBy, + ] + ); + return findById(result.insertId); +} + +export async function update(id, data) { + await pool.query( + `UPDATE recurring_tasks SET + name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?, + interval_value = ?, next_due_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ? + WHERE id = ?`, + [ + data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, + data.intervalUnit, data.intervalValue, data.nextDueDate, + data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, + data.isActive === false ? 0 : 1, id, + ] + ); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM recurring_tasks WHERE id = ?', [id]); +} + +const SQL_UNIT = { days: 'DAY', months: 'MONTH', years: 'YEAR' }; + +// Avanza al siguiente ciclo: suma el intervalo a next_due_date y limpia el recordatorio ya enviado +// (para que el proximo ciclo pueda volver a mandar su propio aviso). +export async function advanceCycle(id) { + const [rows] = await pool.query('SELECT interval_unit, interval_value FROM recurring_tasks WHERE id = ?', [id]); + const row = rows[0]; + if (!row) return null; + const unit = SQL_UNIT[row.interval_unit] || 'MONTH'; + await pool.query( + `UPDATE recurring_tasks + SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL + WHERE id = ?`, + [row.interval_value, id] + ); + return findById(id); +} + +// Candidatas a recordatorio: activas, con la fecha de aviso (next_due_date - reminder_days_before) +// cayendo hoy, en la hora configurada, y que no se haya mandado ya el aviso de este ciclo +// (last_reminder_sent_at se limpia a NULL en cada advanceCycle, asi que "no nulo" = ya avisado). +export async function findDueForReminder() { + const [rows] = await pool.query( + `SELECT r.id, r.name, r.next_due_date, r.reminder_hour, r.project_id, p.name AS project_name, + r.responsible_user_id, u.email AS responsible_email, u.name AS responsible_name, + r.created_by, cu.email AS creator_email, cu.name AS creator_name + FROM recurring_tasks r + LEFT JOIN projects p ON p.id = r.project_id + LEFT JOIN users u ON u.id = r.responsible_user_id + JOIN users cu ON cu.id = r.created_by + WHERE r.is_active = 1 + AND r.last_reminder_sent_at IS NULL + AND DATE_SUB(r.next_due_date, INTERVAL r.reminder_days_before DAY) = CURDATE() + AND HOUR(NOW()) = r.reminder_hour` + ); + return rows; +} + +export async function markReminderSent(id) { + await pool.query('UPDATE recurring_tasks SET last_reminder_sent_at = NOW() WHERE id = ?', [id]); +} diff --git a/backend/src/modules/recurring-tasks/routes.js b/backend/src/modules/recurring-tasks/routes.js new file mode 100644 index 0000000..3a245db --- /dev/null +++ b/backend/src/modules/recurring-tasks/routes.js @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { + getRecurringTasks, postRecurringTask, putRecurringTask, deleteRecurringTask, + postCompleteCycle, postPromoteFromTask, +} from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getRecurringTasks); +router.post('/', postRecurringTask); +router.post('/from-task/:taskId', postPromoteFromTask); +router.put('/:id', putRecurringTask); +router.delete('/:id', deleteRecurringTask); +router.post('/:id/complete', postCompleteCycle); + +export default router; diff --git a/backend/src/modules/recurring-tasks/service.js b/backend/src/modules/recurring-tasks/service.js new file mode 100644 index 0000000..750d053 --- /dev/null +++ b/backend/src/modules/recurring-tasks/service.js @@ -0,0 +1,107 @@ +import * as repo from './repository.js'; +import * as tasksRepo from '../tasks/repository.js'; +import * as activitiesRepo from '../activities/repository.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { sendMail } from '../../utils/mailer.js'; + +const INTERVAL_UNITS = ['days', 'months', 'years']; + +function validate(payload) { + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + if (!INTERVAL_UNITS.includes(payload.intervalUnit)) throw new HttpError(400, 'Periodicidad invalida'); + if (!payload.intervalValue || payload.intervalValue < 1) throw new HttpError(400, 'La periodicidad debe ser al menos 1'); + if (!payload.nextDueDate) throw new HttpError(400, 'La fecha del proximo vencimiento es obligatoria'); + if (payload.reminderHour !== undefined && (payload.reminderHour < 0 || payload.reminderHour > 23)) { + throw new HttpError(400, 'La hora del recordatorio debe estar entre 0 y 23'); + } +} + +export const listRecurringTasks = () => repo.findAll(); + +export async function createRecurringTask(payload, currentUser) { + validate(payload); + const task = await repo.create({ ...payload, createdBy: currentUser.id }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'recurring_task', entityId: task.id }); + return task; +} + +export async function updateRecurringTask(id, payload, currentUser) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + validate(payload); + const task = await repo.update(id, payload); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'recurring_task', entityId: id }); + return task; +} + +export async function removeRecurringTask(id, currentUser) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'recurring_task', entityId: id }); +} + +// Cierra el ciclo actual (ej. "ya renove la licencia") y calcula el proximo vencimiento sumando +// la periodicidad configurada. +export async function completeCycle(id, currentUser) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + const task = await repo.advanceCycle(id); + await logAudit({ userId: currentUser.id, action: 'complete_cycle', entityType: 'recurring_task', entityId: id }); + return task; +} + +// Convierte una tarea puntual de una actividad en una tarea recurrente: la tarea original de la +// actividad NO se borra (queda como referencia de que ya se hizo esta vez), simplemente se crea +// la tarea recurrente enlazada al proyecto de esa actividad para tener contexto. +export async function promoteFromTask(taskId, payload, currentUser) { + const sourceTask = await tasksRepo.findById(taskId); + if (!sourceTask) throw new HttpError(404, 'Tarea no encontrada'); + const activity = await activitiesRepo.findById(sourceTask.activity_id); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + + const merged = { + name: payload.name || sourceTask.name, + description: payload.description || null, + projectId: activity.project_id, + responsibleUserId: payload.responsibleUserId || sourceTask.responsible_user_id || null, + intervalUnit: payload.intervalUnit, + intervalValue: payload.intervalValue, + nextDueDate: payload.nextDueDate, + reminderDaysBefore: payload.reminderDaysBefore, + reminderHour: payload.reminderHour, + }; + validate(merged); + const task = await repo.create({ ...merged, createdBy: currentUser.id }); + await logAudit({ + userId: currentUser.id, action: 'promote_from_task', entityType: 'recurring_task', entityId: task.id, + detail: { sourceTaskId: taskId }, + }); + return task; +} + +// Revisa que tareas recurrentes necesitan aviso HOY a esta hora y manda el correo. Pensada para +// llamarse cada X minutos desde un job en proceso (ver src/jobs/recurringTaskReminders.js) — no +// lanza si falla un correo individual, para que uno malo no tumbe la revision de los demas. +export async function sendDueReminders() { + const due = await repo.findDueForReminder(); + for (const item of due) { + const to = item.responsible_email || item.creator_email; + const recipientName = item.responsible_name || item.creator_name; + try { + await sendMail({ + to, + subject: `Recordatorio: ${item.name} vence el ${item.next_due_date}`, + html: `
Hola ${recipientName},
+La tarea recurrente ${item.name}${item.project_name ? ` (proyecto: ${item.project_name})` : ''} + vence el ${item.next_due_date}.
+Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.
`, + }); + } catch (err) { + console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message); + } + await repo.markReminderSent(item.id); + } + return due.length; +} diff --git a/backend/src/server.js b/backend/src/server.js index c0c38a8..2bdb355 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,6 +1,9 @@ import { app } from './app.js'; import { env } from './config/env.js'; +import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js'; app.listen(env.port, () => { console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); }); + +startRecurringTaskReminderJob(); diff --git a/frontend/package.json b/frontend/package.json index d1620d3..65a8593 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.11.0", + "version": "1.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 0d2a0c4..d840dfb 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -16,6 +16,7 @@ import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx'; import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx'; import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx'; import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; +import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; export default function App() { return ( @@ -33,6 +34,7 @@ export default function App() {