feat: campo "hasta" opcional en Tareas recurrentes

Vacio = se repite sin fin (comportamiento previo). Al completar un
ciclo cuyo proximo vencimiento supera "hasta", la tarea se desactiva
sola. Probado en dev con curl (3 ciclos seguidos, se desactiva justo
al superar la fecha) y validacion de que "hasta" no puede ser anterior
al proximo vencimiento.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-11 10:29:09 -05:00
parent d00b9c50c6
commit 13a69aadc3
11 changed files with 56 additions and 18 deletions
@@ -3,7 +3,7 @@ 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.interval_unit, r.interval_value, r.next_due_date, r.end_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
@@ -25,11 +25,11 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
next_due_date, end_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.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null,
data.reminderDaysBefore ?? 7, data.reminderHour ?? 9,
data.isActive === false ? 0 : 1, data.createdBy,
]
@@ -41,11 +41,11 @@ 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 = ?
interval_value = ?, next_due_date = ?, end_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.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null,
data.reminderDaysBefore ?? 7, data.reminderHour ?? 9,
data.isActive === false ? 0 : 1, id,
]
@@ -60,7 +60,8 @@ export async function remove(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).
// (para que el proximo ciclo pueda volver a mandar su propio aviso). Si el nuevo vencimiento supera
// "hasta cuando" (end_date), la tarea se desactiva sola — ya cumplio su ultimo ciclo.
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];
@@ -72,6 +73,11 @@ export async function advanceCycle(id) {
WHERE id = ?`,
[row.interval_value, id]
);
await pool.query(
`UPDATE recurring_tasks SET is_active = 0
WHERE id = ? AND end_date IS NOT NULL AND next_due_date > end_date`,
[id]
);
return findById(id);
}
@@ -89,6 +95,7 @@ export async function findDueForReminder() {
JOIN users cu ON cu.id = r.created_by
WHERE r.is_active = 1
AND r.last_reminder_sent_at IS NULL
AND (r.end_date IS NULL OR r.next_due_date <= r.end_date)
AND DATE_SUB(r.next_due_date, INTERVAL r.reminder_days_before DAY) = CURDATE()
AND HOUR(NOW()) = r.reminder_hour`
);
@@ -15,6 +15,9 @@ function validate(payload) {
if (payload.reminderHour !== undefined && (payload.reminderHour < 0 || payload.reminderHour > 23)) {
throw new HttpError(400, 'La hora del recordatorio debe estar entre 0 y 23');
}
if (payload.endDate && payload.endDate < payload.nextDueDate) {
throw new HttpError(400, '"Hasta" no puede ser anterior al proximo vencimiento');
}
}
export const listRecurringTasks = () => repo.findAll();
@@ -69,6 +72,7 @@ export async function promoteFromTask(taskId, payload, currentUser) {
intervalUnit: payload.intervalUnit,
intervalValue: payload.intervalValue,
nextDueDate: payload.nextDueDate,
endDate: payload.endDate || null,
reminderDaysBefore: payload.reminderDaysBefore,
reminderHour: payload.reminderHour,
};