feat: version 4.5.0 -- Calendario personal con filtros y capas por tipo

Calendario personal para todos los roles: reuniones que organizo o me invitaron,
proyectos donde soy dueno/miembro, actividades y tareas donde soy responsable y
tareas recurrentes creadas o compartidas conmigo (solo lectura si no soy el creador).
Filtros Ver (Todo / Solo lo mio / Compartido conmigo) y Mostrar (lista de tipos con
Todo); colores por tipo, vencidos en rojo, compartido marcado con franja.
La pestana Calendario del proyecto muestra todas sus reuniones vinculadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 11:14:13 -05:00
parent 6066f27ba0
commit d562351242
18 changed files with 269 additions and 38 deletions
@@ -2,6 +2,9 @@ import { asyncHandler, ok } from '../../utils/http.js';
import * as service from './service.js';
export const getMeetings = asyncHandler(async (req,res) => ok(res, await service.list(req.query, req.currentUser)));
export const getActivities = asyncHandler(async (req,res) => ok(res, await service.activities(req.query, req.currentUser)));
export const getTasks = asyncHandler(async (req,res) => ok(res, await service.tasks(req.query, req.currentUser)));
export const getProjects = asyncHandler(async (req,res) => ok(res, await service.projects(req.query, req.currentUser)));
export const getRecurring = asyncHandler(async (req,res) => ok(res, await service.recurring(req.query, req.currentUser)));
export const getMeeting = asyncHandler(async (req,res) => ok(res, await service.detail(Number(req.params.id), req.currentUser)));
export const postMeeting = asyncHandler(async (req,res) => { const result = await service.create(req.body || {}, req.currentUser); ok(res, result, result.requiresConfirmation ? 200 : 201); });
export const putMeeting = asyncHandler(async (req,res) => ok(res, await service.update(Number(req.params.id), req.body || {}, req.currentUser)));
+25 -10
View File
@@ -32,12 +32,12 @@ export async function findParticipantConflicts(from, to, ids, excludeId = null)
return [...grouped.values()];
}
export async function findVisibleCandidates(from, to, user) {
const roles = ['superadmin', 'admin', 'it_manager'];
const access = roles.includes(user.roleCode) ? '' : `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?) OR (m.project_id IS NOT NULL AND p.deleted_at IS NULL AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=m.project_id AND pm.user_id=?)))`;
// Roles globales ven proyectos; las reuniones sin proyecto siguen privadas.
const globalAccess = !user.modules.includes('projects') ? `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?))` : roles.includes(user.roleCode) ? `AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?) OR (m.project_id IS NOT NULL AND p.deleted_at IS NULL))` : access;
const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ${globalAccess} ORDER BY m.starts_at`, [to, from, user.id, user.id, ...(roles.includes(user.roleCode) || !user.modules.includes('projects') ? [] : [user.id])]);
// Calendario personal: solo reuniones que el usuario organiza o a las que fue invitado, sea cual
// sea su rol. Con projectId (pestana Calendario del proyecto, acceso ya validado en el service)
// devuelve todas las reuniones vinculadas a ese proyecto.
export async function findVisibleCandidates(from, to, user, projectId = null) {
const scope = projectId ? 'AND m.project_id=?' : 'AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?))';
const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ${scope} ORDER BY m.starts_at`, [to, from, ...(projectId ? [projectId] : [user.id, user.id])]);
return rows;
}
@@ -110,11 +110,26 @@ export async function rsvp(id, userId, status) {
const [result] = await pool.query('UPDATE meeting_participants SET rsvp=?,responded_at=NOW() WHERE meeting_id=? AND user_id=?', [status, id, userId]);
return !!result.affectedRows;
}
// Capas personales del calendario: lo que tiene al usuario como responsable (actividades, tareas)
// y los proyectos donde es dueno o miembro.
export async function activityDue(from, to, user) {
const admin = ['superadmin', 'admin', 'it_manager'].includes(user.roleCode);
const [rows] = await pool.query(`SELECT a.id,a.name,a.due_date,a.project_id,p.name AS project_name FROM activities a JOIN projects p ON p.id=a.project_id
WHERE a.due_date>=? AND a.due_date<? AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND (a.responsible_user_id=? ${admin ? 'OR 1=1' : 'OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=p.id AND pm.user_id=?)'})
ORDER BY a.due_date,a.id LIMIT 1000`, admin ? [from, to, user.id] : [from, to, user.id, user.id]);
const [rows] = await pool.query(`SELECT a.id,a.name,a.due_date,a.project_id,p.name AS project_name,st.is_closed,a.created_by FROM activities a JOIN projects p ON p.id=a.project_id JOIN activity_statuses st ON st.id=a.status_id
WHERE a.due_date>=? AND a.due_date<? AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND a.responsible_user_id=?
ORDER BY a.due_date,a.id LIMIT 1000`, [from, to, user.id]);
return rows;
}
export async function taskDue(from, to, user) {
const [rows] = await pool.query(`SELECT t.id,t.name,t.due_date,t.is_done,a.created_by,a.id AS activity_id,a.name AS activity_name,p.id AS project_id,p.name AS project_name
FROM tasks t JOIN activities a ON a.id=t.activity_id JOIN projects p ON p.id=a.project_id
WHERE t.due_date>=? AND t.due_date<? AND t.deleted_at IS NULL AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND t.responsible_user_id=?
ORDER BY t.due_date,t.id LIMIT 1000`, [from, to, user.id]);
return rows;
}
export async function projectDates(from, to, user) {
const [rows] = await pool.query(`SELECT p.id,p.code,p.name,p.start_date,p.estimated_end_date,st.is_closed,p.owner_user_id FROM projects p JOIN project_statuses st ON st.id=p.status_id
WHERE p.deleted_at IS NULL AND (p.owner_user_id=? OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=p.id AND pm.user_id=?))
AND ((p.start_date>=? AND p.start_date<?) OR (p.estimated_end_date>=? AND p.estimated_end_date<?))
ORDER BY p.id LIMIT 500`, [user.id, user.id, from, to, from, to]);
return rows;
}
export async function sendReminderOnce(id, start, recipientIds, title) {
+4 -1
View File
@@ -1,11 +1,14 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { getMeetings, getActivities, getMeeting, postMeeting, putMeeting, deleteMeeting, postCancelOccurrence, putRsvp } from './controller.js';
import { getMeetings, getActivities, getRecurring, getTasks, getProjects, getMeeting, postMeeting, putMeeting, deleteMeeting, postCancelOccurrence, putRsvp } from './controller.js';
const router = Router();
router.use(requireAuth, requireModule('calendar'));
router.get('/', getMeetings);
router.get('/activities', getActivities);
router.get('/recurring', getRecurring);
router.get('/tasks', getTasks);
router.get('/projects', getProjects);
router.post('/', postMeeting);
router.get('/:id', getMeeting);
router.put('/:id', putMeeting);
+64 -3
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import * as repo from './repository.js';
import * as projectsRepo from '../projects/repository.js';
import { canReadProject } from '../projects/service.js';
import * as recurringRepo from '../recurring-tasks/repository.js';
import { createNotification } from '../notifications/service.js';
import { sendMail } from '../../utils/mailer.js';
import { logAudit } from '../../utils/audit.js';
@@ -106,16 +107,76 @@ export async function list(query, user) {
const [from, to] = range(query.from, query.to);
const page = Number(query.page || 1), pageSize = Number(query.pageSize || 1500);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1500 || !Number.isSafeInteger((page-1)*pageSize)) throw new HttpError(400, 'Paginacion invalida');
const rows = await repo.findVisibleCandidates(from, to, user);
let projectId = null;
if (query.projectId) {
projectId = Number(query.projectId);
const project = Number.isSafeInteger(projectId) && projectId > 0 && user.modules.includes('projects') ? await projectsRepo.findById(projectId) : null;
if (!project || !(await canReadProject(user, project))) throw new HttpError(404, 'Proyecto no encontrado');
}
const rows = await repo.findVisibleCandidates(from, to, user, projectId);
const exceptions = await repo.exceptionsFor(rows.map((r) => r.id));
const all = rows.flatMap((r) => expand({ ...r, exceptions: exceptions.get(r.id) || [] }, from, to))
.sort((a, b) => a.occurrence_start.localeCompare(b.occurrence_start) || a.id - b.id);
return { items: all.slice((page-1)*pageSize, page*pageSize), total: all.length, page, pageSize };
// shared = me la compartieron (organiza otra persona); el calendario permite filtrar por origen.
const items = all.slice((page-1)*pageSize, page*pageSize).map((m) => ({ ...m, shared: m.organizer_id !== user.id }));
return { items, total: all.length, page, pageSize };
}
export async function activities(query, user) {
const [from, to] = range(query.from, query.to);
if (!user.modules.includes('projects')) return [];
return repo.activityDue(from.slice(0, 10), to.slice(0, 10), user);
const today = guayaquilNow().slice(0, 10);
return (await repo.activityDue(from.slice(0, 10), to.slice(0, 10), user))
.map((a) => ({ ...a, is_closed: !!a.is_closed, overdue: !a.is_closed && String(a.due_date).slice(0, 10) < today, shared: a.created_by !== user.id }));
}
export async function tasks(query, user) {
const [from, to] = range(query.from, query.to);
if (!user.modules.includes('projects')) return [];
const today = guayaquilNow().slice(0, 10);
return (await repo.taskDue(from.slice(0, 10), to.slice(0, 10), user))
.map((t) => ({ ...t, is_closed: !!t.is_done, overdue: !t.is_done && String(t.due_date).slice(0, 10) < today, shared: t.created_by !== user.id })); // tasks no guarda creador: se usa el de la actividad
}
// Un evento por fecha del proyecto (inicio y fin estimado) que caiga en el rango.
export async function projects(query, user) {
const [from, to] = range(query.from, query.to);
if (!user.modules.includes('projects')) return [];
const a = from.slice(0, 10), b = to.slice(0, 10), today = guayaquilNow().slice(0, 10);
const out = [];
for (const p of await repo.projectDates(a, b, user)) {
const start = p.start_date && String(p.start_date).slice(0, 10), end = p.estimated_end_date && String(p.estimated_end_date).slice(0, 10);
if (start && start >= a && start < b) out.push({ id: p.id, code: p.code, name: p.name, due_date: start, milestone: 'start', is_closed: !!p.is_closed, overdue: false, shared: p.owner_user_id !== user.id });
if (end && end >= a && end < b) out.push({ id: p.id, code: p.code, name: p.name, due_date: end, milestone: 'end', is_closed: !!p.is_closed, overdue: !p.is_closed && end < today, shared: p.owner_user_id !== user.id });
}
return out.sort((x, y) => x.due_date.localeCompare(y.due_date) || x.id - y.id);
}
// Proximos vencimientos de tareas recurrentes en el rango. Replica advanceCycle (DATE_ADD
// sucesivo de MariaDB: 31-ene + 1 mes = 28-feb y de ahi 28-mar), para que el calendario muestre
// las mismas fechas que tendra la tarea al ir completando ciclos. El primero es el pendiente real.
function addInterval(day, unit, value) {
const [y, m, d] = day.split('-').map(Number);
if (unit === 'days') return new Date(Date.UTC(y, m - 1, d + value)).toISOString().slice(0, 10);
const months = (m - 1) + (unit === 'years' ? value * 12 : value);
const ty = y + Math.floor(months / 12), tm = months % 12;
const last = new Date(Date.UTC(ty, tm + 1, 0)).getUTCDate();
return new Date(Date.UTC(ty, tm, Math.min(d, last))).toISOString().slice(0, 10);
}
export async function recurring(query, user) {
const [from, to] = range(query.from, query.to);
if (!user.modules.includes('recurring_tasks')) return [];
const first = from.slice(0, 10), last = to.slice(0, 10), today = guayaquilNow().slice(0, 10);
const out = [];
// Personal: las que el usuario creo o de las que es responsable (se las compartieron), cualquier rol.
// can_open indica si puede abrirla en Tareas recurrentes (alli solo entra el creador o admin).
const mine = (await recurringRepo.findAll()).filter((t) => t.created_by === user.id || t.responsible_user_id === user.id);
for (const task of mine) {
if (!task.is_active || !task.next_due_date) continue;
const canOpen = task.created_by === user.id || ['superadmin', 'admin', 'it_manager'].includes(user.roleCode);
let day = String(task.next_due_date).slice(0, 10);
for (let n = 0; n < 400 && day < last && (!task.end_date || day <= String(task.end_date).slice(0, 10)); n++) {
if (day >= first) out.push({ id: task.id, name: task.name, project_id: task.project_id, project_name: task.project_name, responsible_name: task.responsible_name, can_open: canOpen, shared: task.created_by !== user.id, due_date: day, is_next: n === 0, overdue: n === 0 && day < today });
day = addInterval(day, task.interval_unit, task.interval_value);
}
}
return out.sort((a, b) => a.due_date.localeCompare(b.due_date) || a.id - b.id);
}
export async function create(raw, user) {
const data = await normalize(raw, user);