feat: version 4.4.0 -- Calendario de reuniones

Menu Calendario (Mes/Semana/Dia/Agenda) para agendar reuniones con participantes
internos y externos, RSVP, vinculo a proyecto, vencimientos de actividades,
invitaciones iCalendar por correo, recurrencia con excepciones, recordatorios
in-app y aviso de choques. Migracion 034_meetings.sql.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 11:04:41 -05:00
parent a6cc41666e
commit 6066f27ba0
34 changed files with 986 additions and 16 deletions
@@ -0,0 +1,10 @@
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 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)));
export const deleteMeeting = asyncHandler(async (req,res) => ok(res, await service.cancel(Number(req.params.id), req.currentUser)));
export const postCancelOccurrence = asyncHandler(async (req,res) => ok(res, await service.cancelOccurrence(Number(req.params.id), req.params.date, req.currentUser)));
export const putRsvp = asyncHandler(async (req,res) => ok(res, await service.rsvp(Number(req.params.id), req.body?.status, req.currentUser)));
+40
View File
@@ -0,0 +1,40 @@
const escapeText = (value = '') => String(value).replace(/\\/g, '\\\\').replace(/\r\n|\r|\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
const safeEmail = (value) => String(value || '').replace(/[^A-Za-z0-9._%+@-]/g, '');
const stamp = (s) => String(s).replace(/[-:]/g, '').replace(' ', 'T') + 'Z';
// Hora civil de Guayaquil -> UTC para iCalendar. Ecuador continental no usa DST.
const utc = (s) => stamp(new Date(`${s.replace(' ', 'T')}-05:00`).toISOString().slice(0, 19).replace('T', ' '));
function fold(line) {
const chunks = []; let part = '';
for (const char of line) {
if (Buffer.byteLength(part + char, 'utf8') > 75) { chunks.push(part); part = ' ' + char; } else part += char;
}
chunks.push(part);
return chunks.join('\r\n');
}
export function buildIcs(meeting, method = 'REQUEST', occurrenceStart = null) {
const duration = new Date(`${meeting.ends_at.replace(' ', 'T')}-05:00`) - new Date(`${meeting.starts_at.replace(' ', 'T')}-05:00`);
const start = occurrenceStart || meeting.starts_at;
const end = occurrenceStart ? new Date(new Date(`${start.replace(' ', 'T')}-05:00`).getTime() + duration - 5 * 3600000).toISOString().slice(0, 19).replace('T', ' ') : meeting.ends_at;
const allDay = !!meeting.all_day;
const dateValue = (s) => s.slice(0, 10).replace(/-/g, '');
const exclusiveEnd = (s) => new Date(new Date(`${s.slice(0,10)}T00:00:00Z`).getTime() + 86400000).toISOString().slice(0,10).replace(/-/g,'');
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//LinkedCorp//Project Calendar//ES', 'CALSCALE:GREGORIAN', 'X-WR-TIMEZONE:America/Guayaquil', `METHOD:${method}`, 'BEGIN:VEVENT', `UID:${meeting.uid}@project.linkedcorp.net`, `SEQUENCE:${meeting.sequence_number}`, `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')}`,
allDay ? `DTSTART;VALUE=DATE:${dateValue(start)}` : `DTSTART:${utc(start)}`,
allDay ? `DTEND;VALUE=DATE:${exclusiveEnd(end)}` : `DTEND:${utc(end)}`,
`SUMMARY:${escapeText(meeting.title)}`, `DESCRIPTION:${escapeText(meeting.description)}`, `LOCATION:${escapeText(meeting.location)}`, `ORGANIZER:mailto:${safeEmail(meeting.organizer_email)}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`];
for (const participant of meeting.participants || []) lines.push(`ATTENDEE;RSVP=TRUE;PARTSTAT=${({accepted:'ACCEPTED',declined:'DECLINED',tentative:'TENTATIVE'})[participant.rsvp] || 'NEEDS-ACTION'}:mailto:${safeEmail(participant.email)}`);
for (const email of meeting.external_emails || []) lines.push(`ATTENDEE;RSVP=FALSE:mailto:${safeEmail(email)}`);
if (occurrenceStart) lines.push(allDay ? `RECURRENCE-ID;VALUE=DATE:${dateValue(start)}` : `RECURRENCE-ID:${utc(start)}`);
if (meeting.video_url) lines.push(`URL:${meeting.video_url}`);
const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null;
if (rule && !occurrenceStart) {
const parts = [`FREQ=${rule.frequency.toUpperCase()}`, `INTERVAL=${rule.interval}`];
if (rule.frequency === 'weekly') parts.push(`BYDAY=${rule.weekdays.map((i) => ['MO','TU','WE','TH','FR','SA','SU'][i]).join(',')}`);
if (rule.count) parts.push(`COUNT=${rule.count}`);
if (rule.until) parts.push(`UNTIL=${allDay ? dateValue(rule.until) : utc(`${rule.until} 23:59:59`)}`);
lines.push(`RRULE:${parts.join(';')}`);
}
if (meeting.exceptions?.length && !occurrenceStart) lines.push(allDay ? `EXDATE;VALUE=DATE:${meeting.exceptions.map(dateValue).join(',')}` : `EXDATE:${meeting.exceptions.map(utc).join(',')}`);
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.map(fold).join('\r\n') + '\r\n';
}
@@ -0,0 +1,77 @@
import { HttpError } from '../../utils/http.js';
// DATETIME representa hora civil de America/Guayaquil (UTC-5 sin DST). Date en UTC se usa
// solo para aritmética de calendario; no convierte estos valores a la zona del servidor.
const parse = (s) => new Date(`${s.replace(' ', 'T')}Z`);
const format = (d) => d.toISOString().slice(0, 19).replace('T', ' ');
export const addMinutes = (s, n) => format(new Date(parse(s).getTime() + n * 60000));
export const durationMinutes = (a, b) => (parse(b) - parse(a)) / 60000;
export const guayaquilNow = () => format(new Date(Date.now() - 5 * 3600000));
function* occurrenceStarts(start, rule, max = 365) {
const first = parse(start);
if (!rule) { yield start; return; }
let emitted = 0;
const accept = (d) => d >= first && d.getUTCFullYear() <= 9999 && (!rule.until || format(d).slice(0, 10) <= rule.until);
if (rule.frequency === 'daily') {
for (let n = 0; emitted < max && (!rule.count || emitted < rule.count); n++) {
const d = new Date(first.getTime() + n * rule.interval * 86400000);
if (!accept(d)) break;
emitted++; yield format(d);
}
} else if (rule.frequency === 'weekly') {
const monday = new Date(first.getTime() - ((first.getUTCDay() + 6) % 7) * 86400000);
for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) {
const week = new Date(monday.getTime() + block * rule.interval * 7 * 86400000);
if (week.getUTCFullYear() > 9999 || (rule.until && format(week).slice(0, 10) > rule.until)) break;
for (const weekday of rule.weekdays) {
const d = new Date(week.getTime() + weekday * 86400000);
if (d < first) continue;
if (!accept(d)) return;
emitted++; yield format(d);
if (emitted >= max || (rule.count && emitted >= rule.count)) return;
}
}
} else if (rule.frequency === 'monthly') {
const time = [first.getUTCHours(), first.getUTCMinutes(), first.getUTCSeconds()];
for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) {
const totalMonths = first.getUTCFullYear() * 12 + first.getUTCMonth() + block * rule.interval;
const year = Math.floor(totalMonths / 12), month = totalMonths % 12;
if (year > 9999 || (rule.until && `${year}-${String(month+1).padStart(2,'0')}-01` > rule.until)) break;
const d = new Date(Date.UTC(year, month, first.getUTCDate(), ...time));
if (d.getUTCMonth() !== month) continue; // p. ej. no hay 31 de febrero.
if (!accept(d)) break;
emitted++; yield format(d);
}
}
}
export function validateRecurrence(raw, start) {
if (!raw) return null;
const { frequency, interval = 1, weekdays, until, count } = raw;
if (!['daily', 'weekly', 'monthly'].includes(frequency) || !Number.isInteger(interval) || interval < 1 || interval > 365) throw new HttpError(400, 'Recurrencia invalida');
if ((!!until) === (!!count)) throw new HttpError(400, 'Indica fecha final o numero de ocurrencias');
if (count && (!Number.isInteger(count) || count < 1 || count > 365)) throw new HttpError(400, 'Maximo 365 ocurrencias');
if (until && (typeof until !== 'string' || !/^\d{4}-\d\d-\d\d$/.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`)) || new Date(`${until}T00:00:00Z`).toISOString().slice(0,10) !== until || until < start.slice(0, 10))) throw new HttpError(400, 'Fecha final de recurrencia invalida');
if (frequency === 'weekly' && (!Array.isArray(weekdays) || !weekdays.length || weekdays.some((v) => !Number.isInteger(v) || v < 0 || v > 6))) throw new HttpError(400, 'Dias semanales invalidos');
if (frequency === 'weekly' && !weekdays.includes((parse(start).getUTCDay() + 6) % 7)) throw new HttpError(400, 'El primer dia debe estar incluido');
const rule = { frequency, interval, weekdays: frequency === 'weekly' ? [...new Set(weekdays)].sort() : [], until: until || null, count: count || null };
const occurrences = [...occurrenceStarts(start, rule, 366)];
if (occurrences.length > 365) throw new HttpError(400, 'Maximo 365 ocurrencias');
if (count && occurrences.length < count) throw new HttpError(400, 'La serie supera el rango de fechas admitido');
return rule;
}
export function expand(meeting, from, to) {
if (meeting.cancelled_at) return [];
const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null;
const duration = durationMinutes(meeting.starts_at, meeting.ends_at);
const exclusions = new Set(meeting.exceptions || []);
const out = [];
for (const start of occurrenceStarts(meeting.starts_at, rule)) {
if (start >= to) break;
const end = addMinutes(start, duration);
if (end > from && !exclusions.has(start)) out.push({ ...meeting, occurrence_start: start, occurrence_end: end });
}
return out;
}
+133
View File
@@ -0,0 +1,133 @@
import { pool } from '../../config/db.js';
import * as notificationsRepo from '../notifications/repository.js';
const base = `SELECT m.*, u.name AS organizer_name, u.email AS organizer_email, p.name AS project_name FROM meetings m
JOIN users u ON u.id=m.organizer_id LEFT JOIN projects p ON p.id=m.project_id`;
export async function findById(id) {
const [[row]] = await pool.query(`${base} WHERE m.id=?`, [id]);
if (!row) return null;
const [users] = await pool.query(`SELECT mp.user_id, mp.rsvp, u.name, u.email FROM meeting_participants mp JOIN users u ON u.id=mp.user_id WHERE mp.meeting_id=?`, [id]);
const [externals] = await pool.query('SELECT email FROM meeting_external_participants WHERE meeting_id=?', [id]);
const [exceptions] = await pool.query('SELECT occurrence_start FROM meeting_exceptions WHERE meeting_id=?', [id]);
return { ...row, participants: users, external_emails: externals.map((r) => r.email), exceptions: exceptions.map((r) => r.occurrence_start) };
}
export async function findCandidates(from, to) {
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 > ?) ORDER BY m.starts_at`, [to, from]);
return rows;
}
export async function findParticipantConflicts(from, to, ids, excludeId = null) {
const [rows] = await pool.query(`SELECT m.*, mp.user_id AS conflict_user_id, u.name AS conflict_name
FROM meeting_participants mp JOIN meetings m ON m.id=mp.meeting_id JOIN users u ON u.id=mp.user_id
WHERE mp.user_id IN (${ids.map(() => '?').join(',')}) AND m.cancelled_at IS NULL AND m.starts_at<?
AND (m.recurrence_json IS NOT NULL OR m.ends_at>?) AND (? IS NULL OR m.id<>?)
ORDER BY m.starts_at`, [...ids, to, from, excludeId, excludeId]);
const grouped = new Map();
for (const row of rows) {
if (!grouped.has(row.id)) grouped.set(row.id, { ...row, conflict_names: [] });
grouped.get(row.id).conflict_names.push(row.conflict_name);
}
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])]);
return rows;
}
export async function exceptionsFor(ids) {
if (!ids.length) return new Map();
const [rows] = await pool.query(`SELECT meeting_id, occurrence_start FROM meeting_exceptions WHERE meeting_id IN (${ids.map(() => '?').join(',')})`, ids);
const map = new Map();
for (const row of rows) { if (!map.has(row.meeting_id)) map.set(row.meeting_id, []); map.get(row.meeting_id).push(row.occurrence_start); }
return map;
}
export async function activeUsers(ids) {
if (!ids.length) return [];
const [rows] = await pool.query(`SELECT id,name,email FROM users WHERE id IN (${ids.map(() => '?').join(',')}) AND is_active=1 AND status='active'`, ids);
return rows;
}
async function replaceParticipants(conn, id, data) {
if (data.participantUserIds.length) await conn.query(`DELETE FROM meeting_participants WHERE meeting_id=? AND user_id NOT IN (${data.participantUserIds.map(() => '?').join(',')})`, [id, ...data.participantUserIds]);
else await conn.query('DELETE FROM meeting_participants WHERE meeting_id=?', [id]);
await conn.query('DELETE FROM meeting_external_participants WHERE meeting_id=?', [id]);
for (const userId of data.participantUserIds) await conn.query('INSERT IGNORE INTO meeting_participants (meeting_id,user_id) VALUES (?,?)', [id, userId]);
for (const email of data.externalEmails) await conn.query('INSERT INTO meeting_external_participants (meeting_id,email) VALUES (?,?)', [id, email]);
}
export async function create(data, organizerId, uid) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [result] = await conn.query(`INSERT INTO meetings (uid,title,description,starts_at,ends_at,all_day,location,video_url,project_id,organizer_id,reminder_minutes,recurrence_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, [uid, data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, organizerId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null]);
await replaceParticipants(conn, result.insertId, data);
await conn.commit();
return findById(result.insertId);
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
}
export async function update(id, data, version, resetExceptions) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [result] = await conn.query(`UPDATE meetings SET title=?,description=?,starts_at=?,ends_at=?,all_day=?,location=?,video_url=?,project_id=?,reminder_minutes=?,recurrence_json=?,sequence_number=sequence_number+1,edit_version=edit_version+1
WHERE id=? AND edit_version=? AND cancelled_at IS NULL`, [data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null, id, version]);
if (!result.affectedRows) { await conn.rollback(); return null; }
await replaceParticipants(conn, id, data);
if (resetExceptions) await conn.query('DELETE FROM meeting_exceptions WHERE meeting_id=?', [id]);
await conn.commit();
return findById(id);
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
}
export async function cancel(id) {
const [result] = await pool.query('UPDATE meetings SET cancelled_at=NOW(),sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=? AND cancelled_at IS NULL', [id]);
return result.affectedRows ? findById(id) : null;
}
export async function cancelOccurrence(id, date, userId) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [[row]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]);
if (!row || row.cancelled_at) { await conn.rollback(); return null; }
const [insert] = await conn.query('INSERT IGNORE INTO meeting_exceptions (meeting_id,occurrence_start,created_by) VALUES (?,?,?)', [id, date, userId]);
if (!insert.affectedRows) { await conn.rollback(); return null; }
await conn.query('UPDATE meetings SET sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=?', [id]);
await conn.commit();
return findById(id);
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
}
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;
}
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]);
return rows;
}
export async function sendReminderOnce(id, start, recipientIds, title) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [[meeting]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]);
const [[exception]] = await conn.query('SELECT 1 AS excluded FROM meeting_exceptions WHERE meeting_id=? AND occurrence_start=?', [id, start]);
if (!meeting || meeting.cancelled_at || exception) { await conn.rollback(); return false; }
const [r] = await conn.query('INSERT IGNORE INTO meeting_reminders (meeting_id,occurrence_start) VALUES (?,?)', [id, start]);
if (!r.affectedRows) { await conn.rollback(); return false; }
for (const userId of recipientIds) await notificationsRepo.create({ userId, type: 'meeting_reminder', message: title.slice(0,255), entityType: 'meeting', entityId: id }, conn);
await conn.commit();
return true;
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
}
+15
View File
@@ -0,0 +1,15 @@
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';
const router = Router();
router.use(requireAuth, requireModule('calendar'));
router.get('/', getMeetings);
router.get('/activities', getActivities);
router.post('/', postMeeting);
router.get('/:id', getMeeting);
router.put('/:id', putMeeting);
router.delete('/:id', deleteMeeting);
router.post('/:id/occurrences/:date/cancel', postCancelOccurrence);
router.put('/:id/rsvp', putRsvp);
export default router;
+198
View File
@@ -0,0 +1,198 @@
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 { createNotification } from '../notifications/service.js';
import { sendMail } from '../../utils/mailer.js';
import { logAudit } from '../../utils/audit.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { expand, validateRecurrence, durationMinutes, guayaquilNow, addMinutes } from './recurrence.js';
import { buildIcs } from './ical.js';
const DATE = /^\d{4}-\d\d-\d\d \d\d:\d\d(?::\d\d)?$/;
const EMAIL = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i;
const ROLES = ['admin', 'superadmin'];
const htmlEscape = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[c]);
const normalizeDate = (s) => {
if (typeof s !== 'string' || !DATE.test(s) || Number(s.slice(0, 4)) < 1000) return null;
const short = `${s.slice(0, 16)}:00`;
const date = new Date(`${short.replace(' ', 'T')}Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 19).replace('T', ' ') === short ? short : null;
};
function range(from, to) {
const a = normalizeDate(from), b = normalizeDate(to);
if (!a || !b || a >= b || durationMinutes(a, b) > 93 * 1440) throw new HttpError(400, 'Rango invalido (maximo 93 dias)');
return [a, b];
}
async function normalize(raw, actor) {
const title = String(raw.title || '').trim();
const description = String(raw.description || '').trim();
const location = String(raw.location || '').trim();
let videoUrl = String(raw.videoUrl || '').trim();
const startsAt = normalizeDate(raw.startsAt), endsAt = normalizeDate(raw.endsAt);
if (!title || title.length > 160 || /[\r\n\u0000]/.test(title) || description.length > 5000 || location.length > 255 || videoUrl.length > 2048) throw new HttpError(400, 'Titulo o texto invalido');
if (!startsAt || !endsAt || endsAt <= startsAt || durationMinutes(startsAt, endsAt) > 30 * 1440) throw new HttpError(400, 'Horario invalido');
if (videoUrl && (!/^https?:\/\//i.test(videoUrl) || /[\u0000-\u001f\u007f]/.test(videoUrl))) throw new HttpError(400, 'Enlace http/https invalido');
try { if (videoUrl) videoUrl = new URL(videoUrl).href; } catch { throw new HttpError(400, 'Enlace invalido'); }
const reminderMinutes = raw.reminderMinutes === null || raw.reminderMinutes === '' || raw.reminderMinutes === undefined ? null : Number(raw.reminderMinutes);
if (reminderMinutes !== null && ![5,10,15,30,60,1440].includes(reminderMinutes)) throw new HttpError(400, 'Recordatorio invalido');
const projectId = raw.projectId ? Number(raw.projectId) : null;
if (projectId !== null && (!Number.isSafeInteger(projectId) || projectId <= 0)) throw new HttpError(400, 'Proyecto invalido');
if (projectId) {
if (!actor.modules.includes('projects')) throw new HttpError(403, 'Sin acceso al modulo Proyectos');
const project = await projectsRepo.findById(projectId);
if (!project || !(await canReadProject(actor, project))) throw new HttpError(404, 'Proyecto no encontrado');
}
if (!Array.isArray(raw.participantUserIds || []) || !Array.isArray(raw.externalEmails || [])) throw new HttpError(400, 'Participantes invalidos');
const ids = [...new Set((raw.participantUserIds || []).map(Number))];
const emails = [...new Set((raw.externalEmails || []).map((e) => String(e).trim().toLowerCase()))];
if (ids.length + emails.length > 50 || ids.some((id) => !Number.isSafeInteger(id) || id <= 0) || emails.some((e) => e.length > 160 || !EMAIL.test(e))) throw new HttpError(400, 'Participantes invalidos (maximo 50)');
const users = await repo.activeUsers(ids);
if (users.length !== ids.length) throw new HttpError(400, 'Selecciona solo usuarios activos');
return { title, description, location, videoUrl: videoUrl || null, startsAt, endsAt, allDay: !!raw.allDay, reminderMinutes, projectId, participantUserIds: ids.filter((id) => id !== actor.id), externalEmails: emails, recurrence: validateRecurrence(raw.recurrence, startsAt) };
}
async function visible(meeting, user) {
if (meeting.organizer_id === user.id || meeting.participants.some((p) => p.user_id === user.id)) return true;
if (!meeting.project_id || !user.modules.includes('projects')) return false;
const project = await projectsRepo.findById(meeting.project_id);
return !!project && canReadProject(user, project);
}
export async function detail(id, user) {
if (!Number.isSafeInteger(id) || id <= 0) throw new HttpError(404, 'Reunion no encontrada');
const row = await repo.findById(id);
if (!row || !(await visible(row, user))) throw new HttpError(404, 'Reunion no encontrada');
return row;
}
function canManage(row, user) { return row.organizer_id === user.id || ROLES.includes(user.roleCode); }
async function notify(row, action, extraIds = []) {
const recipients = [...new Set([...row.participants.map((p) => p.user_id), ...extraIds])];
await Promise.allSettled(recipients.map((userId) => createNotification({ userId, type: `meeting_${action}`, message: row.title.slice(0, 255), entityType: 'meeting', entityId: row.id })));
}
async function mail(row, method, recipientsOverride = null, occurrenceStart = null) {
const recipients = recipientsOverride || [...new Set([row.organizer_email, ...row.participants.map((p) => p.email), ...row.external_emails].filter(Boolean))];
const html = `<p><strong>${htmlEscape(row.title)}</strong></p><p>${htmlEscape(row.description).replace(/\n/g, '<br>')}</p><p>${htmlEscape(occurrenceStart || row.starts_at)} – ${htmlEscape(row.ends_at)} (America/Guayaquil)</p><p>${htmlEscape(row.location)}</p>${row.video_url ? `<p><a href="${htmlEscape(row.video_url)}">${htmlEscape(row.video_url)}</a></p>` : ''}`;
await Promise.allSettled(recipients.map((to) => sendMail({ to, subject: `${method === 'CANCEL' ? 'Reunión cancelada / Meeting cancelled' : 'Invitación / Invitation'}: ${row.title}`, html, icalEvent: { method, content: buildIcs(row, method, occurrenceStart), filename: 'reunion.ics' } })));
}
function dispatch(row, action, method = 'REQUEST', extraIds = []) {
setImmediate(() => { void Promise.allSettled([notify(row, action, extraIds), mail(row, method)]); });
}
function deferMail(row, method, recipients = null, occurrenceStart = null) {
setImmediate(() => { void mail(row, method, recipients, occurrenceStart).catch((err) => console.error('[meetings] correo:', err.message)); });
}
function overlaps(a, b) {
let i = 0, j = 0;
while (i < a.length && j < b.length) {
if (a[i].occurrence_start < b[j].occurrence_end && a[i].occurrence_end > b[j].occurrence_start) return true;
if (a[i].occurrence_end <= b[j].occurrence_end) i++; else j++;
}
return false;
}
async function conflictWarnings(data, excludeId) {
const ids = data.participantUserIds;
if (!ids.length) return [];
const proposed = expand({ starts_at: data.startsAt, ends_at: data.endsAt, recurrence_json: data.recurrence ? JSON.stringify(data.recurrence) : null, exceptions: [] }, data.startsAt, '9999-12-31 23:59:59');
const end = proposed.at(-1)?.occurrence_end || data.endsAt;
const rows = await repo.findParticipantConflicts(data.startsAt, end, ids, excludeId || null);
const exceptionMap = await repo.exceptionsFor(rows.map((r) => r.id));
const conflicts = new Set();
for (const row of rows) {
const existing = expand({ ...row, exceptions: exceptionMap.get(row.id) || [] }, data.startsAt, end);
if (overlaps(proposed, existing)) row.conflict_names.forEach((name) => conflicts.add(name));
}
return [...conflicts];
}
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);
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 };
}
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);
}
export async function create(raw, user) {
const data = await normalize(raw, user);
const warnings = await conflictWarnings(data);
if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings };
const row = await repo.create(data, user.id, randomUUID());
await logAudit({ userId: user.id, action: 'create', entityType: 'meeting', entityId: row.id });
dispatch(row, 'created');
return { meeting: row, warnings };
}
export async function update(id, raw, user) {
const before = await detail(id, user);
if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para editar');
if (before.cancelled_at) throw new HttpError(409, 'Reunion cancelada');
requireEditVersion(raw.editVersion);
const data = await normalize(raw, user);
const warnings = await conflictWarnings(data, id);
if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings };
const resetExceptions = before.starts_at !== data.startsAt || before.ends_at !== data.endsAt || before.recurrence_json !== (data.recurrence ? JSON.stringify(data.recurrence) : null);
const row = requireUpdated(await repo.update(id, data, raw.editVersion, resetExceptions));
await logAudit({ userId: user.id, action: 'update', entityType: 'meeting', entityId: id });
const currentEmails = new Set([...row.participants.map((p) => p.email), ...row.external_emails]);
const removedEmails = [...new Set([...before.participants.map((p) => p.email), ...before.external_emails])].filter((email) => !currentEmails.has(email));
const currentIds = new Set(row.participants.map((p) => p.user_id));
const removedIds = before.participants.filter((p) => !currentIds.has(p.user_id)).map((p) => p.user_id);
if (removedEmails.length) deferMail(row, 'CANCEL', removedEmails);
if (before.starts_at !== row.starts_at || before.ends_at !== row.ends_at || before.location !== row.location || before.video_url !== row.video_url || before.recurrence_json !== row.recurrence_json || removedIds.length || before.participants.length !== row.participants.length) dispatch(row, 'updated', 'REQUEST', removedIds);
else deferMail(row, 'REQUEST');
return { meeting: row, warnings };
}
export async function cancel(id, user) {
const before = await detail(id, user);
if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar');
if (before.cancelled_at) throw new HttpError(409, 'Reunion ya cancelada');
const row = requireUpdated(await repo.cancel(id));
await logAudit({ userId: user.id, action: 'cancel', entityType: 'meeting', entityId: id });
dispatch(row, 'cancelled', 'CANCEL');
return { cancelled: true };
}
export async function cancelOccurrence(id, date, user) {
const before = await detail(id, user);
if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar');
if (!before.recurrence_json) throw new HttpError(400, 'Esta reunion no es recurrente');
const start = normalizeDate(date);
if (!start || !expand(before, start, addMinutes(start, 1)).some((r) => r.occurrence_start === start)) throw new HttpError(400, 'Ocurrencia invalida');
const row = requireUpdated(await repo.cancelOccurrence(id, start, user.id));
await logAudit({ userId: user.id, action: 'cancel_occurrence', entityType: 'meeting', entityId: id, detail: { start } });
dispatch(row, 'occurrence_cancelled');
deferMail(row, 'CANCEL', null, start);
return row;
}
export async function rsvp(id, status, user) {
if (!['accepted','declined','tentative'].includes(status)) throw new HttpError(400, 'Respuesta invalida');
const row = await detail(id, user);
if (row.cancelled_at) throw new HttpError(409, 'Reunion cancelada');
if (!(await repo.rsvp(id, user.id, status))) throw new HttpError(403, 'Solo el participante puede responder');
await logAudit({ userId: user.id, action: 'rsvp', entityType: 'meeting', entityId: id, detail: { status } });
if (row.organizer_id !== user.id) {
setImmediate(() => { void createNotification({ userId: row.organizer_id, type: `meeting_rsvp_${status}`, message: `${user.name || ''} · ${row.title}`.slice(0, 255), entityType: 'meeting', entityId: id }).catch(() => {}); });
}
return repo.findById(id);
}
export async function sendDueReminders() {
const now = guayaquilNow(), end = addMinutes(now, 1441);
const rows = await repo.findCandidates(now, end);
const map = await repo.exceptionsFor(rows.map((r) => r.id));
let count = 0;
for (const row of rows) {
if (!row.reminder_minutes) continue;
const occurrences = expand({ ...row, exceptions: map.get(row.id) || [] }, now, end);
for (const occurrence of occurrences) {
const due = addMinutes(occurrence.occurrence_start, -row.reminder_minutes);
if (due > now || due < addMinutes(now, -2)) continue;
const full = await repo.findById(row.id);
const recipientIds = [...new Set([row.organizer_id, ...full.participants.map((p) => p.user_id)])];
if (await repo.sendReminderOnce(row.id, occurrence.occurrence_start, recipientIds, row.title)) count++;
}
}
return count;
}