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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user