feat: recordatorios periódicos; supervisor invita auxiliares; owner edita usuarios; precios planes y MSI

This commit is contained in:
Horux Dev
2026-06-25 20:56:39 +00:00
parent 0bded50e57
commit 57c4908e68
12 changed files with 770 additions and 102 deletions

View File

@@ -13,6 +13,8 @@ const createRecordatorioSchema = z.object({
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
notas: z.string().max(2000).optional(),
privado: z.boolean().optional(),
recurrencia: z.enum(['unica', 'mensual', 'bimestral', 'trimestral', 'anual']).default('unica'),
fechaFin: z.string().min(8).optional(),
});
const updateRecordatorioSchema = z.object({
@@ -107,7 +109,7 @@ export async function createRecordatorio(req: Request, res: Response, next: Next
const evento = await recordatoriosService.createRecordatorio(
req.tenantPool!,
req.user!.userId,
{ ...data, tipo: 'custom', recurrencia: 'unica' }
{ ...data, tipo: 'custom' }
);
res.status(201).json(evento);

View File

@@ -260,7 +260,11 @@ export async function removeAuxiliar(req: Request, res: Response, next: NextFunc
// Supervisores available (for dropdown)
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
try {
const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
const allSupervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
// Un supervisor solo se ve a si mismo en el dropdown (no puede asignar a otro supervisor)
const supervisores = isSupervisor(req)
? allSupervisores.filter(s => s.userId === req.user!.userId)
: allSupervisores;
return res.json({ data: supervisores });
} catch (err) { return next(err); }
}

View File

@@ -70,14 +70,24 @@ export async function inviteUsuario(req: Request, res: Response, next: NextFunct
}
const data = inviteSchema.parse(req.body);
// Los supervisores solo pueden invitar clientes
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
// Los supervisores solo pueden invitar clientes y auxiliares
if (req.user!.role === 'supervisor' && !['cliente', 'auxiliar'].includes(data.role)) {
throw new AppError(403, 'Los supervisores solo pueden invitar clientes y auxiliares');
}
// Validate: auxiliar requires a supervisor
if (data.role === 'auxiliar' && !data.supervisorUserId) {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
// Un supervisor que invita un auxiliar se asigna a sí mismo por defecto
if (req.user!.role === 'supervisor') {
data.supervisorUserId = req.user!.userId;
} else {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
}
}
// Un supervisor solo puede asignar auxiliares a sí mismo
if (req.user!.role === 'supervisor' && data.role === 'auxiliar' && data.supervisorUserId !== req.user!.userId) {
throw new AppError(403, 'Solo puedes asignar auxiliares a tu propia supervisión');
}
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data);

View File

@@ -7,6 +7,7 @@ import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
import { startNotificationsJob } from './jobs/notifications.job.js';
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js';
import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js';
const PORT = parseInt(env.PORT, 10);
@@ -25,6 +26,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
startSatSyncJob();
startMetricasInvalidationsJob();
startSatSyncMonitorJob();
startRecordatoriosPeriodicosJob();
if (sendRealEmails) {
startWeeklyUpdateJob();
startNotificationsJob();

View File

@@ -0,0 +1,75 @@
/**
* Extensión periódica de recordatorios recurrentes.
*
* Cada vez que corre, itera los tenants activos y para cada serie periódica
* activa genera nuevas instancias futuras hasta mantener un horizonte mínimo
* (default 24 meses).
*
* Programado diariamente a las 6:00 AM (America/Mexico_City) porque es una
* tarea liviana y nos asegura que siempre haya instancias disponibles.
*/
import cron from 'node-cron';
import { prisma, tenantDb } from '../config/database.js';
import { extenderSeriesActivas } from '../services/recordatorios.service.js';
const SCHEDULE = '0 6 * * *'; // 06:00 AM diario
let task: ReturnType<typeof cron.schedule> | null = null;
export async function runRecordatoriosPeriodicosJob(): Promise<{
tenants: number;
series: number;
instancias: number;
}> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let seriesTotal = 0;
let instanciasTotal = 0;
for (const tenant of tenants) {
if (!tenant.databaseName) continue;
try {
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const result = await extenderSeriesActivas(pool);
if (result.series > 0) {
console.log(`[Recordatorios Periodicos] ${tenant.rfc}: ${result.instancias} instancias en ${result.series} series`);
}
seriesTotal += result.series;
instanciasTotal += result.instancias;
} catch (err: any) {
console.error(`[Recordatorios Periodicos] Error en ${tenant.rfc}:`, err.message || err);
}
}
return { tenants: tenants.length, series: seriesTotal, instancias: instanciasTotal };
}
export function startRecordatoriosPeriodicosJob(): void {
if (task) {
console.warn('[Recordatorios Periodicos Cron] Ya iniciado');
return;
}
task = cron.schedule(SCHEDULE, async () => {
try {
const result = await runRecordatoriosPeriodicosJob();
if (result.series > 0) {
console.log(`[Recordatorios Periodicos Cron] ${result.tenants} tenants — ${result.instancias} instancias en ${result.series} series`);
}
} catch (err: any) {
console.error('[Recordatorios Periodicos Cron] Error general:', err.message || err);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[Recordatorios Periodicos Cron] Programado: ${SCHEDULE} (06:00 AM diario America/Mexico_City)`);
}
export function stopRecordatoriosPeriodicosJob(): void {
if (task) {
task.stop();
task = null;
}
}

View File

@@ -0,0 +1,19 @@
-- Recordatorios periódicos: maestro + instancias materializadas en la misma tabla
ALTER TABLE recordatorios
ADD COLUMN IF NOT EXISTS recurrencia VARCHAR(20) DEFAULT 'unica' NOT NULL,
ADD COLUMN IF NOT EXISTS serie_id INTEGER REFERENCES recordatorios(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS activo BOOLEAN DEFAULT true NOT NULL,
ADD COLUMN IF NOT EXISTS fecha_inicio DATE,
ADD COLUMN IF NOT EXISTS fecha_fin DATE;
-- Backfill seguro para filas existentes (el default ya cubre recurrencia/activo, pero dejamos explícito)
UPDATE recordatorios
SET recurrencia = 'unica',
activo = true
WHERE recurrencia IS NULL
OR activo IS NULL;
-- Índices para consultas de serie y regeneración
CREATE INDEX IF NOT EXISTS recordatorios_serie_id_idx ON recordatorios(serie_id);
CREATE INDEX IF NOT EXISTS recordatorios_recurrencia_activo_idx ON recordatorios(recurrencia, activo);

View File

@@ -10,7 +10,7 @@ router.use(authenticate);
router.use(tenantMiddleware);
// Static routes first
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
router.get('/supervisores', authorize('owner', 'supervisor'), ctrl.getSupervisores);
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);

View File

@@ -378,6 +378,7 @@ export async function processProximosRecordatorios(
WHERE completado = false
AND fecha_limite = (CURRENT_DATE + ${dias})::date
AND ${col} IS NULL
AND (serie_id IS NOT NULL OR recurrencia = 'unica')
`);
for (const r of rows) {

View File

@@ -1,77 +1,221 @@
import type { Pool } from 'pg';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
export type RecurrenciaRecordatorio = 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
const RECURRENCIA_DELTA_MESES: Record<RecurrenciaRecordatorio, number> = {
unica: 0,
mensual: 1,
bimestral: 2,
trimestral: 3,
anual: 12,
};
interface RecordatorioRow {
id: number;
titulo: string;
descripcion: string | null;
fecha_limite: Date;
notas: string | null;
completado: boolean;
privado: boolean;
creado_por: string;
created_at: Date;
recurrencia: RecurrenciaRecordatorio;
serie_id: number | null;
activo: boolean;
fecha_inicio: Date | null;
fecha_fin: Date | null;
}
function toISODate(d: Date): string {
return d.toISOString().split('T')[0];
}
function startOfDay(d: Date): Date {
const r = new Date(d);
r.setHours(0, 0, 0, 0);
return r;
}
function addMonthsLocal(date: Date, months: number): Date {
const result = new Date(date);
const day = result.getDate();
result.setMonth(result.getMonth() + months);
// Si el mes resultante no tiene el mismo día, volver al último día del mes anterior
if (result.getDate() !== day) {
result.setDate(0);
}
return startOfDay(result);
}
function calcularSiguienteFecha(fecha: Date, recurrencia: RecurrenciaRecordatorio): Date {
return addMonthsLocal(fecha, RECURRENCIA_DELTA_MESES[recurrencia]);
}
function generarFechasInstancias(
fechaInicio: Date,
recurrencia: RecurrenciaRecordatorio,
fechaFin: Date | null | undefined,
maxMesesHorizonte = 24,
): Date[] {
const delta = RECURRENCIA_DELTA_MESES[recurrencia];
if (delta === 0) return [];
const inicio = startOfDay(fechaInicio);
const limiteHorizonte = addMonthsLocal(new Date(), maxMesesHorizonte);
const limite = fechaFin ? startOfDay(fechaFin) : limiteHorizonte;
const fechas: Date[] = [];
let current = inicio;
while (current <= limite) {
fechas.push(new Date(current));
current = calcularSiguienteFecha(current, recurrencia);
}
return fechas;
}
function rowToEventoFiscal(r: RecordatorioRow): EventoFiscal {
return {
id: r.id,
titulo: r.titulo,
descripcion: r.descripcion || '',
tipo: 'custom' as const,
fechaLimite: toISODate(r.fecha_limite),
recurrencia: r.recurrencia,
completado: r.completado,
notas: r.notas,
privado: r.privado,
creadoPor: r.creado_por,
createdAt: r.created_at?.toISOString(),
// metadata extra para el frontend
serieId: r.serie_id ?? undefined,
esPeriodico: r.recurrencia !== 'unica',
} as EventoFiscal;
}
async function findById(pool: Pool, id: number): Promise<RecordatorioRow | null> {
const { rows } = await pool.query<RecordatorioRow>(
`SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
FROM recordatorios WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
async function getMaestroDesdeId(pool: Pool, id: number): Promise<RecordatorioRow | null> {
const row = await findById(pool, id);
if (!row) return null;
if (row.serie_id === null) return row;
return findById(pool, row.serie_id);
}
async function generarInstancias(
pool: Pool,
maestro: RecordatorioRow,
maxMesesHorizonte = 24,
): Promise<number> {
if (!maestro.fecha_inicio) return 0;
if (maestro.recurrencia === 'unica') return 0;
const fechas = generarFechasInstancias(
maestro.fecha_inicio,
maestro.recurrencia,
maestro.fecha_fin ?? undefined,
maxMesesHorizonte,
);
if (fechas.length === 0) return 0;
let insertadas = 0;
for (const fecha of fechas) {
const { rowCount } = await pool.query(
`INSERT INTO recordatorios (
titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
)
VALUES ($1, $2, $3, $4, false, $5, $6, 'unica', $7, true, $8, $9)
ON CONFLICT DO NOTHING`,
[
maestro.titulo,
maestro.descripcion,
toISODate(fecha),
maestro.notas,
maestro.privado,
maestro.creado_por,
maestro.id,
toISODate(maestro.fecha_inicio),
maestro.fecha_fin ? toISODate(maestro.fecha_fin) : null,
]
);
insertadas += rowCount ?? 0;
}
return insertadas;
}
/**
* Obtiene recordatorios visibles para el usuario.
* - Públicos: todos los del tenant
* - Privados: solo los creados por el usuario
* Excluye los maestros periódicos (serie_id IS NULL AND recurrencia != 'unica')
* para no duplicar eventos en el calendario.
*/
export async function getRecordatorios(
pool: Pool,
userId: string,
año: number
): Promise<EventoFiscal[]> {
const { rows } = await pool.query(`
SELECT id, titulo, descripcion, fecha_limite as "fechaLimite",
notas, completado, privado, creado_por as "creadoPor",
created_at as "createdAt"
const { rows } = await pool.query<RecordatorioRow>(`
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
FROM recordatorios
WHERE EXTRACT(YEAR FROM fecha_limite) = $1
AND activo = true
AND (privado = false OR creado_por = $2)
AND NOT (serie_id IS NULL AND recurrencia <> 'unica')
ORDER BY fecha_limite
`, [año, userId]);
return rows.map(r => ({
id: r.id,
titulo: r.titulo,
descripcion: r.descripcion || '',
tipo: 'custom' as const,
fechaLimite: r.fechaLimite instanceof Date
? r.fechaLimite.toISOString().split('T')[0]
: String(r.fechaLimite).split('T')[0],
recurrencia: 'unica' as const,
completado: r.completado,
notas: r.notas,
privado: r.privado,
creadoPor: r.creadoPor,
createdAt: r.createdAt?.toISOString(),
}));
return rows.map(rowToEventoFiscal);
}
export async function createRecordatorio(
pool: Pool,
userId: string,
data: EventoCreate & { privado?: boolean }
data: EventoCreate & { privado?: boolean; fechaFin?: string | null }
): Promise<EventoFiscal> {
const { rows } = await pool.query(`
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, privado, creado_por)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
notas, completado, privado, creado_por as "creadoPor",
created_at as "createdAt"
const recurrencia = (data.recurrencia as RecurrenciaRecordatorio) || 'unica';
const fechaFin = data.fechaFin ? new Date(data.fechaFin + 'T00:00:00') : null;
const fechaLimite = new Date(data.fechaLimite + 'T00:00:00');
const { rows } = await pool.query<RecordatorioRow>(`
INSERT INTO recordatorios (
titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
)
VALUES ($1, $2, $3, $4, false, $5, $6, $7, NULL, true, $8, $9)
RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
`, [
data.titulo,
data.descripcion || null,
data.fechaLimite,
toISODate(fechaLimite),
data.notas || null,
data.privado ?? false,
userId,
recurrencia,
toISODate(fechaLimite),
fechaFin ? toISODate(fechaFin) : null,
]);
const r = rows[0];
return {
id: r.id,
titulo: r.titulo,
descripcion: r.descripcion || '',
tipo: 'custom',
fechaLimite: r.fechaLimite instanceof Date
? r.fechaLimite.toISOString().split('T')[0]
: String(r.fechaLimite).split('T')[0],
recurrencia: 'unica',
completado: r.completado,
notas: r.notas,
createdAt: r.createdAt?.toISOString(),
};
const maestro = rows[0];
if (recurrencia !== 'unica') {
await generarInstancias(pool, maestro);
}
return rowToEventoFiscal(maestro);
}
export async function updateRecordatorio(
@@ -80,54 +224,123 @@ export async function updateRecordatorio(
id: number,
data: EventoUpdate & { privado?: boolean }
): Promise<EventoFiscal | null> {
// Verify ownership or public
const { rows: existing } = await pool.query(
`SELECT id, creado_por FROM recordatorios WHERE id = $1`,
[id]
);
const row = await findById(pool, id);
if (!row) return null;
if (existing.length === 0) return null;
// Completar una instancia individual de una serie periódica
const soloCompletado =
data.completado !== undefined &&
data.titulo === undefined &&
data.descripcion === undefined &&
data.fechaLimite === undefined &&
data.notas === undefined &&
data.privado === undefined;
const sets: string[] = [];
const params: any[] = [];
if (soloCompletado && row.serie_id !== null) {
await pool.query(`UPDATE recordatorios SET completado = $1, updated_at = NOW() WHERE id = $2`, [
data.completado,
id,
]);
const updated = await findById(pool, id);
return updated ? rowToEventoFiscal(updated) : null;
}
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
if (!maestro) return null;
// Recordatorio único: editar directamente
if (maestro.recurrencia === 'unica') {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); }
if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); }
if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); }
if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); }
if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); }
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
if (sets.length === 0) return rowToEventoFiscal(maestro);
sets.push(`updated_at = NOW()`);
params.push(maestro.id);
const { rows } = await pool.query<RecordatorioRow>(`
UPDATE recordatorios SET ${sets.join(', ')}
WHERE id = $${idx}
RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
`, params);
return rows[0] ? rowToEventoFiscal(rows[0]) : null;
}
// Serie periódica: editar maestro y propagar a instancias futuras no completadas
const updatesMaestro: string[] = [];
const paramsMaestro: any[] = [];
let idx = 1;
if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); }
if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); }
if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); }
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); }
if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); }
if (data.titulo !== undefined) { updatesMaestro.push(`titulo = $${idx++}`); paramsMaestro.push(data.titulo); }
if (data.descripcion !== undefined) { updatesMaestro.push(`descripcion = $${idx++}`); paramsMaestro.push(data.descripcion); }
if (data.notas !== undefined) { updatesMaestro.push(`notas = $${idx++}`); paramsMaestro.push(data.notas); }
if (data.privado !== undefined) { updatesMaestro.push(`privado = $${idx++}`); paramsMaestro.push(data.privado); }
if (sets.length === 0) return null;
const nuevaFechaInicio = data.fechaLimite ? new Date(data.fechaLimite + 'T00:00:00') : null;
if (nuevaFechaInicio) {
updatesMaestro.push(`fecha_limite = $${idx++}`);
paramsMaestro.push(toISODate(nuevaFechaInicio));
updatesMaestro.push(`fecha_inicio = $${idx++}`);
paramsMaestro.push(toISODate(nuevaFechaInicio));
}
sets.push(`updated_at = NOW()`);
params.push(id);
if (updatesMaestro.length > 0) {
updatesMaestro.push(`updated_at = NOW()`);
paramsMaestro.push(maestro.id);
await pool.query(
`UPDATE recordatorios SET ${updatesMaestro.join(', ')} WHERE id = $${idx}`,
paramsMaestro
);
}
const { rows } = await pool.query(`
UPDATE recordatorios SET ${sets.join(', ')}
WHERE id = $${idx}
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
notas, completado, privado, creado_por as "creadoPor",
created_at as "createdAt"
`, params);
// Propagar campos de contenido a instancias futuras no completadas
const updatesInstancias: string[] = [];
const paramsInstancias: any[] = [];
let iIdx = 1;
if (rows.length === 0) return null;
if (data.titulo !== undefined) { updatesInstancias.push(`titulo = $${iIdx++}`); paramsInstancias.push(data.titulo); }
if (data.descripcion !== undefined) { updatesInstancias.push(`descripcion = $${iIdx++}`); paramsInstancias.push(data.descripcion); }
if (data.notas !== undefined) { updatesInstancias.push(`notas = $${iIdx++}`); paramsInstancias.push(data.notas); }
if (data.privado !== undefined) { updatesInstancias.push(`privado = $${iIdx++}`); paramsInstancias.push(data.privado); }
const r = rows[0];
return {
id: r.id,
titulo: r.titulo,
descripcion: r.descripcion || '',
tipo: 'custom',
fechaLimite: r.fechaLimite instanceof Date
? r.fechaLimite.toISOString().split('T')[0]
: String(r.fechaLimite).split('T')[0],
recurrencia: 'unica',
completado: r.completado,
notas: r.notas,
createdAt: r.createdAt?.toISOString(),
};
if (updatesInstancias.length > 0) {
paramsInstancias.push(maestro.id);
await pool.query(
`UPDATE recordatorios
SET ${updatesInstancias.join(', ')}
WHERE serie_id = $${iIdx}
AND fecha_limite >= CURRENT_DATE
AND completado = false`,
paramsInstancias
);
}
// Si cambió la fecha de inicio, regenerar instancias futuras
if (nuevaFechaInicio) {
await pool.query(
`DELETE FROM recordatorios
WHERE serie_id = $1
AND fecha_limite >= CURRENT_DATE
AND completado = false`,
[maestro.id]
);
const maestroActualizado = await findById(pool, maestro.id);
if (maestroActualizado) {
await generarInstancias(pool, maestroActualizado);
}
}
const maestroFinal = await findById(pool, maestro.id);
return maestroFinal ? rowToEventoFiscal(maestroFinal) : null;
}
export async function deleteRecordatorio(
@@ -135,9 +348,70 @@ export async function deleteRecordatorio(
userId: string,
id: number
): Promise<boolean> {
const { rowCount } = await pool.query(
`DELETE FROM recordatorios WHERE id = $1`,
[id]
);
const row = await findById(pool, id);
if (!row) return false;
// Serie periódica: cancelar (desactivar maestro y borrar instancias futuras no completadas)
if (row.recurrencia !== 'unica' || row.serie_id !== null) {
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
if (!maestro) return false;
await pool.query(`UPDATE recordatorios SET activo = false, updated_at = NOW() WHERE id = $1`, [maestro.id]);
await pool.query(
`DELETE FROM recordatorios
WHERE serie_id = $1
AND fecha_limite >= CURRENT_DATE
AND completado = false`,
[maestro.id]
);
return true;
}
// Único: borrar directamente
const { rowCount } = await pool.query(`DELETE FROM recordatorios WHERE id = $1`, [id]);
return (rowCount ?? 0) > 0;
}
/**
* Extiende las series activas para mantener un horizonte futuro de instancias.
* Útil para llamar desde un cron periódico.
*/
export async function extenderSeriesActivas(
pool: Pool,
mesesHorizonte = 24,
): Promise<{ series: number; instancias: number }> {
const { rows: maestros } = await pool.query<RecordatorioRow>(`
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
FROM recordatorios
WHERE serie_id IS NULL
AND recurrencia <> 'unica'
AND activo = true
`);
let seriesProcesadas = 0;
let instanciasCreadas = 0;
const horizonte = addMonthsLocal(new Date(), mesesHorizonte);
for (const maestro of maestros) {
const { rows: ultimas } = await pool.query<{ fecha_limite: Date }>(`
SELECT fecha_limite
FROM recordatorios
WHERE serie_id = $1
ORDER BY fecha_limite DESC
LIMIT 1
`, [maestro.id]);
const ultima = ultimas[0]?.fecha_limite;
if (!ultima || startOfDay(ultima) < addMonthsLocal(new Date(), mesesHorizonte - 6)) {
const creadas = await generarInstancias(pool, maestro, mesesHorizonte);
if (creadas > 0) {
seriesProcesadas++;
instanciasCreadas += creadas;
}
}
}
return { series: seriesProcesadas, instancias: instanciasCreadas };
}