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

@@ -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 };
}