feat: Tareas recurrentes con recordatorio por correo

Menu propio en la barra lateral, independiente de Proyectos:
- Periodicidad configurable (cada N dias/meses/años), proximo
  vencimiento, responsable, proyecto de origen opcional.
- "Completar ciclo" avanza el proximo vencimiento sumando la
  periodicidad y limpia el recordatorio ya enviado.
- Recordatorio por correo automatico N dias antes del vencimiento
  (default 7), a una hora del dia configurable. Un job en el propio
  proceso del backend (setInterval cada 15 min) revisa y envia, sin
  infraestructura nueva (cron del SO, cola, etc).
- Desde la pestaña Tareas de una actividad, cada tarea tiene un boton
  "Convertir en recurrente" que la promueve (la tarea original no se
  borra).

Probado en dev con curl (crear, completar ciclo avanza next_due_date,
promover desde una tarea real, disparo real del job de recordatorios
verificado forzando fecha/hora, segunda corrida no reenvia) y Playwright
(listado, popup de creacion, popup de promocion anidado sobre el detalle
de actividad) sin errores de consola.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-11 10:17:02 -05:00
parent 02e5590b42
commit 72b616bd02
20 changed files with 669 additions and 5 deletions
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.11.0",
"version": "1.12.0",
"private": true,
"type": "module",
"main": "src/server.js",
+2
View File
@@ -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).
@@ -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);
}
@@ -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);
});
@@ -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]);
}
@@ -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;
@@ -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: `<p>Hola ${recipientName},</p>
<p>La tarea recurrente <strong>${item.name}</strong>${item.project_name ? ` (proyecto: ${item.project_name})` : ''}
vence el <strong>${item.next_due_date}</strong>.</p>
<p>Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.</p>`,
});
} catch (err) {
console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message);
}
await repo.markReminderSent(item.id);
}
return due.length;
}
+3
View File
@@ -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();