feat: recordatorios periódicos; supervisor invita auxiliares; owner edita usuarios; precios planes y MSI
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user